Skip to main content

xtask/drift/
mod.rs

1//! Protocol drift detection between the AG-UI TypeScript source of truth and
2//! this repo's Rust event types.
3//!
4//! Why this exists: the Rust event types are a hand-written port of the
5//! upstream Zod schemas. Nothing in the compiler links the two, so upstream can
6//! add an event type and this SDK will keep building, keep passing its tests,
7//! and silently not speak the protocol any more. That is exactly how the
8//! previous community SDK fell ten event types behind without anyone noticing.
9//!
10//! The link is this check:
11//!
12//! * `drift-check` — offline, deterministic, the CI gate. Compares the vendored
13//!   baseline in `xtask/baseline/` against the Rust source, read as text.
14//! * `drift-check --upstream` — additionally asks GitHub whether the baseline
15//!   itself has gone stale. Needs the network, so it is a scheduled job, never
16//!   the required check.
17//! * `drift-check --refresh` — re-captures the baseline. How a human accepts an
18//!   upstream protocol change.
19
20pub 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
31/// Where the vendored baseline lives, relative to the repo root.
32const BASELINE: &str = "xtask/baseline/events.json";
33/// Where the Rust event types live, relative to the repo root.
34const EVENT_DIR: &str = "crates/ag-ui/src/event";
35
36/// Exit codes: 0 clean, 1 drift found, 2 the check itself could not run.
37pub const EXIT_OK: u8 = 0;
38pub const EXIT_DRIFT: u8 = 1;
39
40#[derive(Debug, Default, Clone, Copy)]
41pub struct Args {
42    /// Also check whether the vendored baseline is stale (needs network).
43    pub upstream: bool,
44    /// Re-capture the baseline from upstream (needs network).
45    pub refresh: bool,
46}
47
48/// Runs the check. `Err` means the check could not be performed at all, which
49/// is a different thing from finding drift.
50pub 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
113/// Re-captures the vendored baseline from upstream.
114fn 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
166/// What a fetch of upstream had to say about the vendored baseline.
167struct Freshness {
168    /// The ways the baseline no longer matches upstream.
169    changes: Vec<String>,
170    /// How the extractor read anything the source did not spell out.
171    notes: Vec<String>,
172}
173
174/// Fetches upstream and returns the ways the baseline no longer matches it.
175fn 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, &current),
181        notes: extracted.notes,
182    })
183}
184
185/// Human-readable differences between two snapshots of the upstream surface.
186fn diff_baselines(old: &Baseline, new: &Baseline) -> Vec<String> {
187    let mut out = Vec::new();
188    // The envelope first: a field there lands on every event, so it is the
189    // change a reviewer most needs to see before the per-event ones.
190    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/// Everything the offline comparison found.
259#[derive(Debug, Default)]
260struct Report {
261    missing_in_rust: Vec<baseline::Event>,
262    /// Declared in Rust, but not a member of the tagged union.
263    not_in_union: Vec<RustEvent>,
264    not_in_upstream: Vec<RustEvent>,
265    field_diffs: Vec<FieldDiff>,
266    /// The envelope both sides flatten into every event, when it disagrees.
267    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/// The three ways one payload's fields can disagree with the baseline's.
282#[derive(Debug, Default, PartialEq)]
283struct FieldDelta {
284    /// Upstream field with no Rust counterpart.
285    missing: Vec<baseline::Field>,
286    /// Rust field upstream does not declare.
287    extra: Vec<rust_src::RustField>,
288    /// `(field, required upstream, required in Rust)`.
289    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    /// How many fields are named in it, for a section heading.
320    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/// The `BaseEvent` comparison, which is one struct rather than one per event.
334#[derive(Debug)]
335struct BaseEventDiff {
336    /// Repo-relative file the Rust envelope was read from.
337    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    // `BaseEvent` is in neither the union nor the baseline's event list, so
378    // nothing above would have compared it — and a field there is a field on
379    // every event, which makes it the most expensive thing to miss. That is
380    // not hypothetical: `metadata` arrived on the base schema, and until this
381    // comparison existed the baseline recorded it and no check read it.
382    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    // A payload type that never made it into the union cannot be sent or
400    // received, so it is drift even though the type exists. Only checked when
401    // the union's members were readable at all — otherwise a shape this scanner
402    // does not understand would condemn every event type.
403    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
595/// The field-by-field lines under a heading, shared by the per-event and the
596/// `BaseEvent` sections so the two read identically.
597fn 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
649/// Prints how the extractor read what upstream did not spell out.
650///
651/// Only where a human is deciding something: accepting a refresh, or reading a
652/// report that upstream has moved. An optionality taken from a name rather
653/// than from a Zod chain is a judgement, and the person signing off on the
654/// baseline is the one who should see it.
655fn 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
677/// The repo root, from the compile-time location of this crate.
678fn 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        // Nothing came from the enum: the shape was not understood.
823        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    /// The gap this closes: `metadata` arrived on `BaseEventSchema`, the
853    /// baseline recorded it, and nothing compared it — so a field on all 36
854    /// event types was missing from the Rust envelope with the check green.
855    #[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    /// An envelope this scanner could not find must not condemn the run: the
919    /// same reasoning as an unreadable payload.
920    #[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}