1pub mod baseline;
21pub mod fetch;
22pub mod rust_src;
23pub mod text;
24pub mod upstream;
25
26use std::path::{Path, PathBuf};
27
28use baseline::Baseline;
29use rust_src::{RustEvent, RustSurface};
30
31const BASELINE: &str = "xtask/baseline/events.json";
33const EVENT_DIR: &str = "crates/ag-ui/src/event";
35
36pub const EXIT_OK: u8 = 0;
38pub const EXIT_DRIFT: u8 = 1;
39
40#[derive(Debug, Default, Clone, Copy)]
41pub struct Args {
42 pub upstream: bool,
44 pub refresh: bool,
46}
47
48pub fn run(args: Args) -> Result<u8, String> {
51 let root = repo_root();
52 let baseline_path = root.join(BASELINE);
53 let event_dir = root.join(EVENT_DIR);
54
55 if args.refresh {
56 return refresh(&baseline_path);
57 }
58
59 let baseline = Baseline::load(&baseline_path)?;
60 if !event_dir.is_dir() {
61 return Err(format!(
62 "{} does not exist.\n\
63 drift-check compares the vendored baseline against the Rust event types; \
64 without them there is nothing to compare.",
65 event_dir.display()
66 ));
67 }
68 let rust = rust_src::scan(&event_dir, &root)?;
69 if rust.events.is_empty() {
70 return Err(format!(
71 "no event types found under {}.\n\
72 {} .rs files were read, but none declared a `#[serde(tag = \"type\")]` enum or a \
73 `<Name>Event` payload struct.\n\
74 Either the event types moved, or this scanner needs teaching about a new shape \
75 (xtask/src/drift/rust_src.rs).",
76 event_dir.display(),
77 rust.files.len()
78 ));
79 }
80
81 let report = compare(&baseline, &rust);
82 print!("{}", render(&baseline, &rust, &report));
83
84 let mut exit = if report.is_clean() {
85 EXIT_OK
86 } else {
87 EXIT_DRIFT
88 };
89
90 if args.upstream {
91 println!();
92 match check_upstream(&baseline) {
93 Ok(freshness) => {
94 let stale = !freshness.changes.is_empty();
95 print!("{}", render_upstream(&baseline, &freshness));
96 if stale {
97 exit = EXIT_DRIFT;
98 }
99 }
100 Err(e) => {
101 println!("UPSTREAM FRESHNESS CHECK — could not run");
102 println!(" {}", indent(&e, " "));
103 println!(
104 "\n The offline result above stands; only the freshness check was skipped."
105 );
106 }
107 }
108 }
109
110 Ok(exit)
111}
112
113fn refresh(baseline_path: &Path) -> Result<u8, String> {
115 let previous = Baseline::load(baseline_path).ok();
116 let fetched = fetch::events_ts()?;
117 let extracted = upstream::extract(&fetched.text)?;
118 let next = Baseline::from_upstream(&extracted, fetched.source);
119 next.save(baseline_path)?;
120
121 println!("Refreshed {}", baseline_path.display());
122 println!(
123 " from {}@{} ({} event types, captured {})",
124 next.source.repo,
125 short(&next.source.commit),
126 next.event_types.len(),
127 next.source.fetched_at
128 );
129
130 match previous {
131 None => println!("\n New baseline — review it in full before committing."),
132 Some(previous) => {
133 let changes = diff_baselines(&previous, &next);
134 if changes.is_empty() {
135 println!(
136 "\n No change to the event surface (previous snapshot was {}@{}).",
137 previous.source.repo,
138 short(&previous.source.commit)
139 );
140 } else {
141 println!("\n Changes since {}:", short(&previous.source.commit));
142 for line in &changes {
143 println!(" {line}");
144 }
145 println!(
146 "\n Review these, update crates/ag-ui/src/event to match, then commit \
147 the baseline with that change."
148 );
149 }
150 }
151 }
152 let mut notes = String::new();
153 render_notes(&mut notes, &extracted.notes);
154 print!("{notes}");
155
156 let unparsed = next.events.iter().filter(|e| e.unparsed.is_some()).count();
157 if unparsed > 0 {
158 println!(
159 "\n {unparsed} schema(s) could not be read confidently; their fields are recorded \
160 as unparsed and are reported as warnings, not failures."
161 );
162 }
163 Ok(EXIT_OK)
164}
165
166struct Freshness {
168 changes: Vec<String>,
170 notes: Vec<String>,
172}
173
174fn check_upstream(baseline: &Baseline) -> Result<Freshness, String> {
176 let fetched = fetch::events_ts()?;
177 let extracted = upstream::extract(&fetched.text)?;
178 let current = Baseline::from_upstream(&extracted, fetched.source);
179 Ok(Freshness {
180 changes: diff_baselines(baseline, ¤t),
181 notes: extracted.notes,
182 })
183}
184
185fn diff_baselines(old: &Baseline, new: &Baseline) -> Vec<String> {
187 let mut out = Vec::new();
188 for field in &new.base_event_fields {
191 match old.base_event_fields.iter().find(|f| f.name == field.name) {
192 None => out.push(format!(
193 "~ BaseEvent.{} was added upstream ({})",
194 field.name,
195 optionality(field.required)
196 )),
197 Some(was) if was.required != field.required => out.push(format!(
198 "~ BaseEvent.{} is now {} upstream (was {})",
199 field.name,
200 optionality(field.required),
201 optionality(was.required)
202 )),
203 Some(_) => {}
204 }
205 }
206 for field in &old.base_event_fields {
207 if !new.base_event_fields.iter().any(|f| f.name == field.name) {
208 out.push(format!("~ BaseEvent.{} was removed upstream", field.name));
209 }
210 }
211 for event in &new.event_types {
212 if !old.event_types.contains(event) {
213 out.push(format!("+ {event} was added upstream"));
214 }
215 }
216 for event in &old.event_types {
217 if !new.event_types.contains(event) {
218 out.push(format!("- {event} was removed upstream"));
219 }
220 }
221 for event in &new.events {
222 let Some(before) = old.event(&event.event_type) else {
223 continue;
224 };
225 if before.unparsed.is_some() || event.unparsed.is_some() {
226 continue;
227 }
228 for field in &event.fields {
229 match before.fields.iter().find(|f| f.name == field.name) {
230 None => out.push(format!(
231 "~ {}.{} was added upstream ({})",
232 event.event_type,
233 field.name,
234 optionality(field.required)
235 )),
236 Some(was) if was.required != field.required => out.push(format!(
237 "~ {}.{} is now {} upstream (was {})",
238 event.event_type,
239 field.name,
240 optionality(field.required),
241 optionality(was.required)
242 )),
243 Some(_) => {}
244 }
245 }
246 for field in &before.fields {
247 if !event.fields.iter().any(|f| f.name == field.name) {
248 out.push(format!(
249 "~ {}.{} was removed upstream",
250 event.event_type, field.name
251 ));
252 }
253 }
254 }
255 out
256}
257
258#[derive(Debug, Default)]
260struct Report {
261 missing_in_rust: Vec<baseline::Event>,
262 not_in_union: Vec<RustEvent>,
264 not_in_upstream: Vec<RustEvent>,
265 field_diffs: Vec<FieldDiff>,
266 base_event: Option<BaseEventDiff>,
268 warnings: Vec<String>,
269}
270
271impl Report {
272 fn is_clean(&self) -> bool {
273 self.missing_in_rust.is_empty()
274 && self.not_in_union.is_empty()
275 && self.not_in_upstream.is_empty()
276 && self.field_diffs.is_empty()
277 && self.base_event.is_none()
278 }
279}
280
281#[derive(Debug, Default, PartialEq)]
283struct FieldDelta {
284 missing: Vec<baseline::Field>,
286 extra: Vec<rust_src::RustField>,
288 optionality: Vec<(String, bool, bool)>,
290}
291
292impl FieldDelta {
293 fn between(upstream: &[baseline::Field], rust: &[rust_src::RustField]) -> Self {
294 Self {
295 missing: upstream
296 .iter()
297 .filter(|f| !rust.iter().any(|r| r.name == f.name))
298 .cloned()
299 .collect(),
300 extra: rust
301 .iter()
302 .filter(|r| !upstream.iter().any(|f| f.name == r.name))
303 .cloned()
304 .collect(),
305 optionality: upstream
306 .iter()
307 .filter_map(|f| {
308 let r = rust.iter().find(|r| r.name == f.name)?;
309 (r.required != f.required).then(|| (f.name.clone(), f.required, r.required))
310 })
311 .collect(),
312 }
313 }
314
315 fn is_empty(&self) -> bool {
316 self.missing.is_empty() && self.extra.is_empty() && self.optionality.is_empty()
317 }
318
319 fn len(&self) -> usize {
321 self.missing.len() + self.extra.len() + self.optionality.len()
322 }
323}
324
325#[derive(Debug)]
326struct FieldDiff {
327 event_type: String,
328 rust_type: String,
329 file: String,
330 delta: FieldDelta,
331}
332
333#[derive(Debug)]
335struct BaseEventDiff {
336 file: String,
338 delta: FieldDelta,
339}
340
341fn compare(baseline: &Baseline, rust: &RustSurface) -> Report {
342 let mut report = Report::default();
343
344 for event in &baseline.events {
345 let Some(found) = rust.events.iter().find(|e| e.tag == event.event_type) else {
346 report.missing_in_rust.push(event.clone());
347 continue;
348 };
349 if let Some(reason) = &event.unparsed {
350 report.warnings.push(format!(
351 "{}: the upstream schema could not be read ({reason}); \
352 its fields were not compared",
353 event.event_type
354 ));
355 continue;
356 }
357 let Some(fields) = &found.fields else {
358 report.warnings.push(format!(
359 "{}: no payload fields could be read from the Rust source; \
360 only the event type itself was compared",
361 event.event_type
362 ));
363 continue;
364 };
365
366 let delta = FieldDelta::between(&event.fields, fields);
367 if !delta.is_empty() {
368 report.field_diffs.push(FieldDiff {
369 event_type: event.event_type.clone(),
370 rust_type: found.rust_type.clone().unwrap_or_else(|| "?".to_string()),
371 file: found.file.clone(),
372 delta,
373 });
374 }
375 }
376
377 match &rust.base_event {
383 Some(base) => {
384 let delta = FieldDelta::between(&baseline.base_event_fields, &base.fields);
385 if !delta.is_empty() {
386 report.base_event = Some(BaseEventDiff {
387 file: base.file.clone(),
388 delta,
389 });
390 }
391 }
392 None => report.warnings.push(format!(
393 "no `{}` struct was found under {EVENT_DIR}; the fields every event inherits \
394 were not compared",
395 rust_src::BASE_EVENT
396 )),
397 }
398
399 let union_read = rust.events.iter().any(|e| e.from_enum);
404 for event in &rust.events {
405 if !baseline.event_types.contains(&event.tag) {
406 report.not_in_upstream.push(event.clone());
407 } else if union_read && event.from_struct && !event.from_enum {
408 report.not_in_union.push(event.clone());
409 }
410 }
411
412 report.warnings.extend(rust.notes.iter().cloned());
413 report
414}
415
416fn render(baseline: &Baseline, rust: &RustSurface, report: &Report) -> String {
417 let mut out = String::new();
418 let src = &baseline.source;
419 out.push_str("drift-check\n");
420 out.push_str(&format!(
421 " baseline {BASELINE} ({}@{}, captured {})\n",
422 src.repo,
423 short(&src.commit),
424 src.fetched_at
425 ));
426 out.push_str(&format!(
427 " upstream {} event types\n",
428 baseline.event_types.len()
429 ));
430 out.push_str(&format!(
431 " rust {EVENT_DIR} ({} files, {} event types{})\n",
432 rust.files.len(),
433 rust.events.len(),
434 match &rust.tagged_enum {
435 Some(name) => format!(", tagged enum `{name}`"),
436 None => String::new(),
437 }
438 ));
439
440 if !report.missing_in_rust.is_empty() {
441 out.push_str(&format!(
442 "\nMISSING IN RUST — {}\n",
443 report.missing_in_rust.len()
444 ));
445 out.push_str(
446 " Upstream declares these event types and this SDK does not. A stream that\n \
447 carries one of them cannot be handled.\n\n",
448 );
449 for event in &report.missing_in_rust {
450 out.push_str(&format!(
451 " {:<32}{}\n",
452 event.event_type,
453 event.schema.as_deref().unwrap_or("(no upstream schema)")
454 ));
455 if !event.fields.is_empty() {
456 out.push_str(&format!(
457 " {:<32}fields: {}\n",
458 "",
459 event
460 .fields
461 .iter()
462 .map(|f| format!("{}{}", f.name, if f.required { "" } else { "?" }))
463 .collect::<Vec<_>>()
464 .join(", ")
465 ));
466 }
467 }
468 out.push_str(&format!(
469 "\n Fix: add the payload type under {EVENT_DIR}/ and wire it into the event enum.\n"
470 ));
471 }
472
473 if !report.not_in_union.is_empty() {
474 out.push_str(&format!(
475 "\nNOT IN THE EVENT UNION — {}\n",
476 report.not_in_union.len()
477 ));
478 out.push_str(&format!(
479 " These payload types exist but are not members of `{}`, so nothing can\n \
480 serialize or deserialize them. Upstream declares them, so this is drift.\n\n",
481 rust.tagged_enum.as_deref().unwrap_or("the event enum")
482 ));
483 for event in &report.not_in_union {
484 out.push_str(&format!(
485 " {:<32}{} ({})\n",
486 event.tag,
487 event.rust_type.as_deref().unwrap_or("?"),
488 event.file
489 ));
490 }
491 out.push_str(&format!(
492 "\n Fix: add a variant for each to `{}`.\n",
493 rust.tagged_enum.as_deref().unwrap_or("the event enum")
494 ));
495 }
496
497 if !report.not_in_upstream.is_empty() {
498 out.push_str(&format!(
499 "\nNOT IN UPSTREAM — {}\n",
500 report.not_in_upstream.len()
501 ));
502 out.push_str(
503 " These exist in Rust but are not in the baseline's EventType enum. Either\n \
504 upstream removed them, or the tag is misspelled.\n\n",
505 );
506 for event in &report.not_in_upstream {
507 out.push_str(&format!(
508 " {:<32}{} ({})\n",
509 event.tag,
510 event.rust_type.as_deref().unwrap_or("?"),
511 event.file
512 ));
513 }
514 out.push_str(
515 "\n Fix: correct the tag, delete the type, or — if upstream moved — re-capture\n \
516 the baseline with `cargo run -p xtask -- drift-check --refresh`.\n",
517 );
518 }
519
520 if let Some(base) = &report.base_event {
521 out.push_str(&format!("\nBASE EVENT FIELDS — {}\n", base.delta.len()));
522 out.push_str(&format!(
523 " Every event flattens `{}` in, so a field declared there is a field on all\n \
524 {} of them. It is not a member of the union, so nothing else here reads it.\n\n",
525 rust_src::BASE_EVENT,
526 baseline.event_types.len()
527 ));
528 out.push_str(&format!(
529 " {} -> {}\n",
530 rust_src::BASE_EVENT,
531 base.file
532 ));
533 render_delta(&mut out, &base.delta);
534 }
535
536 if !report.field_diffs.is_empty() {
537 out.push_str(&format!(
538 "\nFIELD MISMATCHES — {}\n",
539 report.field_diffs.len()
540 ));
541 out.push_str(
542 " The event type exists on both sides but its payload does not match.\n \
543 `?` marks an optional field.\n\n",
544 );
545 for diff in &report.field_diffs {
546 out.push_str(&format!(
547 " {} -> {} ({})\n",
548 diff.event_type, diff.rust_type, diff.file
549 ));
550 render_delta(&mut out, &diff.delta);
551 }
552 }
553
554 if !report.warnings.is_empty() {
555 out.push_str(&format!(
556 "\nWARNINGS — {} (not failures)\n",
557 report.warnings.len()
558 ));
559 for warning in &report.warnings {
560 out.push_str(&format!(" {warning}\n"));
561 }
562 }
563
564 out.push('\n');
565 if report.is_clean() {
566 out.push_str(&format!(
567 "OK {} event types match the baseline{}.\n",
568 baseline.event_types.len(),
569 match report.warnings.len() {
570 0 => String::new(),
571 n => format!(", {n} warning(s)"),
572 }
573 ));
574 } else {
575 out.push_str(&format!(
576 "FAILED {} missing in Rust, {} not in the union, {} not upstream, \
577 {} with field mismatches{}.\n",
578 report.missing_in_rust.len(),
579 report.not_in_union.len(),
580 report.not_in_upstream.len(),
581 report.field_diffs.len(),
582 match &report.base_event {
583 Some(base) => format!(", {} on the base event", base.delta.len()),
584 None => String::new(),
585 }
586 ));
587 out.push_str(
588 " The baseline is the protocol. If the baseline is what changed, re-capture\n\
589 \x20 it with `--refresh` and review that diff; otherwise fix the Rust side.\n",
590 );
591 }
592 out
593}
594
595fn render_delta(out: &mut String, delta: &FieldDelta) {
598 for field in &delta.missing {
599 out.push_str(&format!(
600 " missing in Rust {:<24}upstream: {}\n",
601 field.name,
602 optionality(field.required)
603 ));
604 }
605 for field in &delta.extra {
606 out.push_str(&format!(
607 " not upstream {:<24}Rust: {}\n",
608 field.name,
609 optionality(field.required)
610 ));
611 }
612 for (name, up, rs) in &delta.optionality {
613 out.push_str(&format!(
614 " optionality {:<24}upstream: {}, Rust: {}\n",
615 name,
616 optionality(*up),
617 optionality(*rs)
618 ));
619 }
620}
621
622fn render_upstream(baseline: &Baseline, freshness: &Freshness) -> String {
623 let mut out = String::from("UPSTREAM FRESHNESS CHECK\n");
624 out.push_str(&format!(
625 " baseline captured {} from {}@{}\n",
626 baseline.source.fetched_at,
627 baseline.source.repo,
628 short(&baseline.source.commit)
629 ));
630 if freshness.changes.is_empty() {
631 out.push_str("\nOK The vendored baseline still matches upstream.\n");
632 return out;
633 }
634 out.push_str(&format!(
635 "\nSTALE upstream has moved in {} way(s) since the baseline was captured:\n\n",
636 freshness.changes.len()
637 ));
638 for change in &freshness.changes {
639 out.push_str(&format!(" {change}\n"));
640 }
641 render_notes(&mut out, &freshness.notes);
642 out.push_str(
643 "\n Accept these with `cargo run -p xtask -- drift-check --refresh`, then update\n \
644 the Rust types in the same pull request.\n",
645 );
646 out
647}
648
649fn render_notes(out: &mut String, notes: &[String]) {
656 if notes.is_empty() {
657 return;
658 }
659 out.push_str("\n Read from a name rather than from the schema:\n");
660 for note in notes {
661 out.push_str(&format!(" {note}\n"));
662 }
663}
664
665fn optionality(required: bool) -> &'static str {
666 if required { "required" } else { "optional" }
667}
668
669fn short(sha: &str) -> String {
670 sha.chars().take(10).collect()
671}
672
673fn indent(text: &str, prefix: &str) -> String {
674 text.replace('\n', &format!("\n{prefix}"))
675}
676
677fn repo_root() -> PathBuf {
679 Path::new(env!("CARGO_MANIFEST_DIR"))
680 .parent()
681 .expect("xtask/ always has a parent")
682 .to_path_buf()
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use baseline::{Event, Field, Source};
689
690 fn baseline_of(events: Vec<Event>) -> Baseline {
691 Baseline {
692 note: String::new(),
693 format: baseline::FORMAT,
694 source: Source {
695 repo: "ag-ui-protocol/ag-ui".into(),
696 path: "events.ts".into(),
697 commit: "0123456789abcdef".into(),
698 commit_date: "2026-08-01".into(),
699 fetched_at: "2026-08-01".into(),
700 },
701 base_event_fields: vec![],
702 event_types: events.iter().map(|e| e.event_type.clone()).collect(),
703 events,
704 }
705 }
706
707 fn event(ty: &str, fields: &[(&str, bool)]) -> Event {
708 Event {
709 event_type: ty.into(),
710 schema: Some(format!("{ty}Schema")),
711 fields: fields
712 .iter()
713 .map(|(name, required)| Field {
714 name: (*name).into(),
715 required: *required,
716 })
717 .collect(),
718 unparsed: None,
719 }
720 }
721
722 fn rust_event(tag: &str, fields: Option<&[(&str, bool)]>) -> RustEvent {
723 RustEvent {
724 tag: tag.into(),
725 rust_type: Some(format!("{tag}Event")),
726 file: "crates/ag-ui/src/event/x.rs".into(),
727 fields: fields.map(|fields| {
728 fields
729 .iter()
730 .map(|(name, required)| rust_src::RustField {
731 name: (*name).into(),
732 required: *required,
733 })
734 .collect()
735 }),
736 from_enum: false,
737 from_struct: true,
738 }
739 }
740
741 fn surface(events: Vec<RustEvent>) -> RustSurface {
742 RustSurface {
743 events,
744 base_event: Some(rust_src::RustBaseEvent {
745 fields: vec![],
746 file: "crates/ag-ui/src/event/mod.rs".into(),
747 }),
748 tagged_enum: None,
749 files: vec!["crates/ag-ui/src/event/x.rs".into()],
750 notes: vec![],
751 }
752 }
753
754 #[test]
755 fn identical_surfaces_are_clean() {
756 let baseline = baseline_of(vec![event("RAW", &[("event", true), ("source", false)])]);
757 let rust = surface(vec![rust_event(
758 "RAW",
759 Some(&[("event", true), ("source", false)]),
760 )]);
761 let report = compare(&baseline, &rust);
762 assert!(report.is_clean());
763 assert!(render(&baseline, &rust, &report).contains("OK 1 event types match"));
764 }
765
766 #[test]
767 fn reports_each_kind_of_drift() {
768 let baseline = baseline_of(vec![
769 event("RAW", &[("event", true), ("source", false)]),
770 event("ACTIVITY_DELTA", &[("patch", true)]),
771 ]);
772 let rust = surface(vec![
773 rust_event("RAW", Some(&[("event", false), ("extra", true)])),
774 rust_event("MADE_UP", Some(&[])),
775 ]);
776 let report = compare(&baseline, &rust);
777
778 assert_eq!(report.missing_in_rust.len(), 1);
779 assert_eq!(report.missing_in_rust[0].event_type, "ACTIVITY_DELTA");
780 assert_eq!(report.not_in_upstream.len(), 1);
781 assert_eq!(report.field_diffs.len(), 1);
782 let diff = &report.field_diffs[0];
783 assert_eq!(
784 diff.delta
785 .missing
786 .iter()
787 .map(|f| &f.name)
788 .collect::<Vec<_>>(),
789 ["source"]
790 );
791 assert_eq!(
792 diff.delta.extra.iter().map(|f| &f.name).collect::<Vec<_>>(),
793 ["extra"]
794 );
795 assert_eq!(diff.delta.optionality, [("event".to_string(), true, false)]);
796
797 let text = render(&baseline, &rust, &report);
798 assert!(text.contains("MISSING IN RUST — 1"));
799 assert!(text.contains("NOT IN UPSTREAM — 1"));
800 assert!(text.contains("FIELD MISMATCHES — 1"));
801 assert!(text.contains("FAILED"));
802 }
803
804 #[test]
805 fn payload_type_outside_the_union_is_drift() {
806 let baseline = baseline_of(vec![event("RAW", &[]), event("CUSTOM", &[])]);
807 let mut wired = rust_event("RAW", Some(&[]));
808 wired.from_enum = true;
809 let stranded = rust_event("CUSTOM", Some(&[]));
810 let mut rust = surface(vec![wired, stranded]);
811 rust.tagged_enum = Some("Event".into());
812
813 let report = compare(&baseline, &rust);
814 assert_eq!(report.not_in_union.len(), 1);
815 assert_eq!(report.not_in_union[0].tag, "CUSTOM");
816 assert!(render(&baseline, &rust, &report).contains("NOT IN THE EVENT UNION — 1"));
817 }
818
819 #[test]
820 fn a_union_this_scanner_cannot_read_does_not_condemn_every_event() {
821 let baseline = baseline_of(vec![event("RAW", &[]), event("CUSTOM", &[])]);
822 let mut rust = surface(vec![
824 rust_event("RAW", Some(&[])),
825 rust_event("CUSTOM", Some(&[])),
826 ]);
827 rust.tagged_enum = Some("Event".into());
828 assert!(compare(&baseline, &rust).is_clean());
829 }
830
831 #[test]
832 fn unparsed_upstream_schema_warns_instead_of_failing() {
833 let mut events = vec![event("RAW", &[("event", true)])];
834 events[0].unparsed = Some("unsupported modifier `.superRefine()`".into());
835 let baseline = baseline_of(events);
836 let rust = surface(vec![rust_event("RAW", Some(&[("something_else", true)]))]);
837 let report = compare(&baseline, &rust);
838 assert!(report.is_clean());
839 assert_eq!(report.warnings.len(), 1);
840 assert!(render(&baseline, &rust, &report).contains("WARNINGS — 1"));
841 }
842
843 #[test]
844 fn unreadable_rust_payload_warns_instead_of_failing() {
845 let baseline = baseline_of(vec![event("RAW", &[("event", true)])]);
846 let rust = surface(vec![rust_event("RAW", None)]);
847 let report = compare(&baseline, &rust);
848 assert!(report.is_clean());
849 assert_eq!(report.warnings.len(), 1);
850 }
851
852 #[test]
856 fn a_base_event_field_missing_in_rust_is_drift() {
857 let mut baseline = baseline_of(vec![event("RAW", &[])]);
858 baseline.base_event_fields = vec![
859 Field {
860 name: "timestamp".into(),
861 required: false,
862 },
863 Field {
864 name: "metadata".into(),
865 required: false,
866 },
867 ];
868 let mut rust = surface(vec![rust_event("RAW", Some(&[]))]);
869 rust.base_event = Some(rust_src::RustBaseEvent {
870 fields: vec![rust_src::RustField {
871 name: "timestamp".into(),
872 required: false,
873 }],
874 file: "crates/ag-ui/src/event/mod.rs".into(),
875 });
876
877 let report = compare(&baseline, &rust);
878 assert!(!report.is_clean());
879 let base = report.base_event.as_ref().unwrap();
880 assert_eq!(
881 base.delta
882 .missing
883 .iter()
884 .map(|f| &f.name)
885 .collect::<Vec<_>>(),
886 ["metadata"]
887 );
888
889 let text = render(&baseline, &rust, &report);
890 assert!(text.contains("BASE EVENT FIELDS — 1"), "{text}");
891 assert!(text.contains("missing in Rust metadata"), "{text}");
892 assert!(text.contains("1 on the base event"), "{text}");
893 }
894
895 #[test]
896 fn a_base_event_optionality_change_is_drift() {
897 let mut baseline = baseline_of(vec![event("RAW", &[])]);
898 baseline.base_event_fields = vec![Field {
899 name: "metadata".into(),
900 required: false,
901 }];
902 let mut rust = surface(vec![rust_event("RAW", Some(&[]))]);
903 rust.base_event = Some(rust_src::RustBaseEvent {
904 fields: vec![rust_src::RustField {
905 name: "metadata".into(),
906 required: true,
907 }],
908 file: "crates/ag-ui/src/event/mod.rs".into(),
909 });
910
911 let report = compare(&baseline, &rust);
912 assert_eq!(
913 report.base_event.as_ref().unwrap().delta.optionality,
914 [("metadata".to_string(), false, true)]
915 );
916 }
917
918 #[test]
921 fn a_missing_base_event_struct_warns_instead_of_failing() {
922 let baseline = baseline_of(vec![event("RAW", &[])]);
923 let mut rust = surface(vec![rust_event("RAW", Some(&[]))]);
924 rust.base_event = None;
925
926 let report = compare(&baseline, &rust);
927 assert!(report.is_clean());
928 assert_eq!(report.warnings.len(), 1);
929 assert!(
930 report.warnings[0].contains("BaseEvent"),
931 "{:?}",
932 report.warnings
933 );
934 }
935
936 #[test]
937 fn baseline_diff_names_a_base_event_field_that_moved() {
938 let mut old = baseline_of(vec![event("RAW", &[])]);
939 old.base_event_fields = vec![
940 Field {
941 name: "timestamp".into(),
942 required: false,
943 },
944 Field {
945 name: "gone".into(),
946 required: true,
947 },
948 ];
949 let mut new = baseline_of(vec![event("RAW", &[])]);
950 new.base_event_fields = vec![
951 Field {
952 name: "timestamp".into(),
953 required: true,
954 },
955 Field {
956 name: "metadata".into(),
957 required: false,
958 },
959 ];
960 assert_eq!(
961 diff_baselines(&old, &new),
962 [
963 "~ BaseEvent.timestamp is now required upstream (was optional)",
964 "~ BaseEvent.metadata was added upstream (optional)",
965 "~ BaseEvent.gone was removed upstream",
966 ]
967 );
968 }
969
970 #[test]
971 fn baseline_diff_names_what_moved_upstream() {
972 let old = baseline_of(vec![event("RAW", &[("event", true)]), event("GONE", &[])]);
973 let new = baseline_of(vec![
974 event("RAW", &[("event", true), ("source", false)]),
975 event("BRAND_NEW", &[]),
976 ]);
977 let changes = diff_baselines(&old, &new);
978 assert_eq!(
979 changes,
980 [
981 "+ BRAND_NEW was added upstream",
982 "- GONE was removed upstream",
983 "~ RAW.source was added upstream (optional)",
984 ]
985 );
986 }
987}