Skip to main content

xtask/drift/
rust_src.rs

1//! Extracts the event surface from `crates/ag-ui/src/event/**/*.rs`.
2//!
3//! Deliberately a text scanner, not a compiler: `drift-check` has to keep
4//! working while `ag-ui` is mid-refactor and does not build. It reads
5//! whichever of the two shapes the crate uses (or both at once):
6//!
7//! * a `#[serde(tag = "type")]` enum, where the wire tag is either an explicit
8//!   `#[serde(rename = "TEXT_MESSAGE_START")]` or the variant name;
9//! * a macro table that generates that enum, whose entries pair a payload type
10//!   with its tag (`TextMessageStart(TextMessageStartEvent) => "TEXT_MESSAGE_START"`);
11//! * per-event payload structs named `<Name>Event`, where the wire tag follows
12//!   from the type name.
13//!
14//! It also reads the `BaseEvent` envelope on its own. That struct is not an
15//! event type — it is flattened into every payload — so it belongs to none of
16//! the shapes above, and its fields are fields of all 36 events.
17
18use std::collections::BTreeMap;
19use std::path::{Path, PathBuf};
20
21use crate::drift::text::{
22    apply_rename_all, blank_lifetimes, match_delim, pascal_to_screaming_snake, read_ident,
23    split_top_level, strip_comments,
24};
25
26/// The envelope struct every payload flattens in. Not an event type itself,
27/// but its fields are fields of every event, so it is read separately.
28pub const BASE_EVENT: &str = "BaseEvent";
29
30/// Structs that end in `Event` but are envelope plumbing, not event types.
31const NOT_EVENTS: &[&str] = &[BASE_EVENT, "AnyEvent"];
32
33/// One field of an event payload, named as it appears on the wire.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RustField {
36    pub name: String,
37    pub required: bool,
38}
39
40/// One event type as the Rust source declares it.
41#[derive(Debug, Clone)]
42pub struct RustEvent {
43    /// Wire tag, e.g. `TEXT_MESSAGE_START`.
44    pub tag: String,
45    /// The Rust type carrying the payload, when there is one.
46    pub rust_type: Option<String>,
47    /// Repo-relative file the type was found in.
48    pub file: String,
49    /// Payload fields, excluding anything `#[serde(flatten)]`ed in. `None` when
50    /// only the enum variant was found and no payload type could be located.
51    pub fields: Option<Vec<RustField>>,
52    pub from_enum: bool,
53    pub from_struct: bool,
54}
55
56/// The envelope every event payload flattens in, as the Rust source declares
57/// it.
58#[derive(Debug, Clone)]
59pub struct RustBaseEvent {
60    /// Its fields, named as they appear on the wire.
61    pub fields: Vec<RustField>,
62    /// Repo-relative file it was found in.
63    pub file: String,
64}
65
66/// Everything the scan found.
67#[derive(Debug, Clone, Default)]
68pub struct RustSurface {
69    /// One entry per wire tag, sorted.
70    pub events: Vec<RustEvent>,
71    /// The `BaseEvent` envelope, when the module declares one. `None` is not
72    /// drift — the comparison warns instead, the same way an unreadable
73    /// payload does.
74    pub base_event: Option<RustBaseEvent>,
75    /// Name of the `#[serde(tag = "type")]` enum, when the crate has one yet.
76    pub tagged_enum: Option<String>,
77    /// Repo-relative paths scanned, sorted.
78    pub files: Vec<String>,
79    /// Things worth mentioning that are not drift (e.g. an unresolved payload).
80    pub notes: Vec<String>,
81}
82
83/// Scans `dir` recursively for the event surface.
84pub fn scan(dir: &Path, repo_root: &Path) -> Result<RustSurface, String> {
85    let mut files = Vec::new();
86    collect_rs_files(dir, &mut files)?;
87    files.sort();
88
89    let mut structs: BTreeMap<String, (Vec<RustField>, String)> = BTreeMap::new();
90    let mut struct_order: Vec<(String, String)> = Vec::new();
91    let mut variants: Vec<UnionMember> = Vec::new();
92    let mut tagged_enum = None;
93    let mut notes = Vec::new();
94    let mut any_flattened_base = false;
95    let mut flattening_structs: Vec<String> = Vec::new();
96
97    for path in &files {
98        let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
99        let rel = relative(path, repo_root);
100        // Lifetimes first: an apostrophe that is not a quote has to stop
101        // looking like one before anything counts delimiters. See
102        // [`blank_lifetimes`].
103        for item in scan_items(&strip_comments(&blank_lifetimes(&text))) {
104            match item.kind {
105                ItemKind::Struct => {
106                    let parsed = parse_fields(&item.body, container_rename_all(&item.attrs));
107                    if parsed.flattened {
108                        any_flattened_base = true;
109                        flattening_structs.push(item.name.clone());
110                    }
111                    struct_order.push((item.name.clone(), rel.clone()));
112                    structs.insert(item.name, (parsed.fields, rel.clone()));
113                }
114                ItemKind::Enum => {
115                    if !serde_args(&item.attrs)
116                        .iter()
117                        .any(|arg| arg_value(arg, "tag").as_deref() == Some("type"))
118                    {
119                        continue;
120                    }
121                    let rename_all = container_rename_all(&item.attrs);
122                    let rename_fields = serde_args(&item.attrs)
123                        .iter()
124                        .find_map(|arg| arg_value(arg, "rename_all_fields"));
125                    let members = parse_variants(
126                        &item.body,
127                        rename_all.as_deref(),
128                        rename_fields.as_deref(),
129                        &rel,
130                    );
131                    // `type` is a common discriminator, and the event union is
132                    // not the only thing that uses it — `SubagentOutcome` is
133                    // tagged `type` with variants `success` and `suspended`.
134                    // Every AG-UI event tag is SCREAMING_SNAKE, so an enum
135                    // whose variants are not is somebody else's union, and
136                    // reading it would invent two event types that do not
137                    // exist. An enum with no readable variants at all is the
138                    // macro-generated union, whose members come from the
139                    // macro's table instead, so it still counts.
140                    if !members.is_empty() && !members.iter().any(|m| is_wire_tag(&m.tag)) {
141                        continue;
142                    }
143                    if let Some(previous) = tagged_enum.replace(item.name.clone()) {
144                        notes.push(format!(
145                            "two `#[serde(tag = \"type\")]` enums found ({previous} and {}); \
146                             both were read",
147                            item.name
148                        ));
149                    }
150                    variants.extend(members.into_iter().filter(|m| is_wire_tag(&m.tag)));
151                }
152                ItemKind::Macro => variants.extend(parse_macro_table(&item.body, &rel)),
153                ItemKind::Other => {}
154            }
155        }
156    }
157
158    let mut events: BTreeMap<String, RustEvent> = BTreeMap::new();
159
160    for UnionMember {
161        tag,
162        payload,
163        inline_fields,
164        mut file,
165    } in variants
166    {
167        // Point at the payload struct when there is one: that is the file a
168        // person has to open to fix a field mismatch.
169        let (rust_type, fields) = match (&payload, inline_fields) {
170            (_, Some(fields)) => (payload.clone(), Some(fields)),
171            (Some(name), None) => {
172                let found = structs.get(name);
173                if let Some((_, struct_file)) = found {
174                    file.clone_from(struct_file);
175                }
176                (Some(name.clone()), found.map(|(fields, _)| fields.clone()))
177            }
178            (None, None) => (None, Some(Vec::new())),
179        };
180        if fields.is_none() {
181            if let Some(name) = &rust_type {
182                notes.push(format!(
183                    "variant for {tag} carries `{name}`, which was not found under the event \
184                     module; its fields were not compared"
185                ));
186            }
187        }
188        events.insert(
189            tag.clone(),
190            RustEvent {
191                tag,
192                rust_type,
193                file,
194                fields,
195                from_enum: true,
196                from_struct: false,
197            },
198        );
199    }
200
201    for (name, file) in struct_order {
202        if !name.ends_with("Event") || NOT_EVENTS.contains(&name.as_str()) {
203            continue;
204        }
205        // When the crate carries the envelope by flattening a `BaseEvent` into
206        // each payload, that flatten is the marker of an event type; helper
207        // structs whose names happen to end in `Event` are skipped. If nothing
208        // in the tree flattens, fall back to accepting every `*Event` struct.
209        if any_flattened_base && !flattening_structs.contains(&name) {
210            continue;
211        }
212        let tag = pascal_to_screaming_snake(name.trim_end_matches("Event"));
213        let fields = structs.get(&name).map(|(fields, _)| fields.clone());
214        match events.get_mut(&tag) {
215            Some(existing) => {
216                existing.from_struct = true;
217                if existing.fields.is_none() {
218                    existing.fields.clone_from(&fields);
219                    existing.rust_type = Some(name.clone());
220                    existing.file.clone_from(&file);
221                }
222            }
223            None => {
224                events.insert(
225                    tag.clone(),
226                    RustEvent {
227                        tag,
228                        rust_type: Some(name),
229                        file,
230                        fields,
231                        from_enum: false,
232                        from_struct: true,
233                    },
234                );
235            }
236        }
237    }
238
239    // The envelope is deliberately not an event, so nothing above would have
240    // picked it up — and a field added there is a field on every event.
241    let base_event = structs.get(BASE_EVENT).map(|(fields, file)| RustBaseEvent {
242        fields: fields.clone(),
243        file: file.clone(),
244    });
245
246    Ok(RustSurface {
247        events: events.into_values().collect(),
248        base_event,
249        tagged_enum,
250        files: files.iter().map(|p| relative(p, repo_root)).collect(),
251        notes,
252    })
253}
254
255fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
256    let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
257    for entry in entries {
258        let path = entry.map_err(|e| format!("{}: {e}", dir.display()))?.path();
259        if path.is_dir() {
260            collect_rs_files(&path, out)?;
261        } else if path.extension().is_some_and(|e| e == "rs") {
262            out.push(path);
263        }
264    }
265    Ok(())
266}
267
268fn relative(path: &Path, root: &Path) -> String {
269    path.strip_prefix(root)
270        .unwrap_or(path)
271        .to_string_lossy()
272        .replace('\\', "/")
273}
274
275#[derive(Debug, PartialEq, Eq, Clone, Copy)]
276enum ItemKind {
277    Struct,
278    Enum,
279    /// A macro definition or invocation. Its body is walked for items and read
280    /// for a tag table.
281    Macro,
282    Other,
283}
284
285#[derive(Debug)]
286struct Item {
287    kind: ItemKind,
288    name: String,
289    attrs: Vec<String>,
290    body: String,
291}
292
293/// Walks top-level items, carrying each one's attributes with it.
294///
295/// `#[cfg(test)]` modules are skipped whole; other inline modules are walked.
296fn scan_items(src: &str) -> Vec<Item> {
297    let mut items = Vec::new();
298    let mut attrs: Vec<String> = Vec::new();
299    let mut pos = 0usize;
300
301    while pos < src.len() {
302        let line_end = src[pos..].find('\n').map(|i| pos + i).unwrap_or(src.len());
303        let line = src[pos..line_end].trim();
304
305        if line.is_empty() {
306            pos = line_end + 1;
307            continue;
308        }
309
310        if line.starts_with("#[") || line.starts_with("#![") {
311            let open = pos + src[pos..line_end].find('[').unwrap_or(0);
312            match match_delim(src, open) {
313                Some(close) => {
314                    attrs.push(src[open + 1..close].trim().to_string());
315                    pos = close + 1;
316                }
317                None => pos = line_end + 1,
318            }
319            continue;
320        }
321
322        match item_head(line) {
323            Some((kind, name)) => {
324                let name_at = pos + src[pos..line_end].find(&name).unwrap_or(0) + name.len();
325                let (body, next) = item_body(src, name_at);
326                let is_test_mod = kind == ItemKind::Other
327                    && line.contains("mod ")
328                    && attrs.iter().any(|a| a.replace(' ', "") == "cfg(test)");
329                match kind {
330                    ItemKind::Other if line.contains("mod ") && !is_test_mod => {
331                        items.extend(scan_items(&body));
332                    }
333                    ItemKind::Other => {}
334                    // A macro body can both declare items (the generated enum)
335                    // and hold the tag table, so it is read as each in turn.
336                    ItemKind::Macro => {
337                        items.extend(scan_items(&body));
338                        items.push(Item {
339                            kind,
340                            name,
341                            attrs: std::mem::take(&mut attrs),
342                            body,
343                        });
344                    }
345                    ItemKind::Struct | ItemKind::Enum => items.push(Item {
346                        kind,
347                        name,
348                        attrs: std::mem::take(&mut attrs),
349                        body,
350                    }),
351                }
352                attrs.clear();
353                pos = next;
354            }
355            None => {
356                attrs.clear();
357                pos = line_end + 1;
358            }
359        }
360    }
361    items
362}
363
364/// Recognises `pub struct Foo`, `enum Bar`, `pub(crate) mod baz` and
365/// `some_macro! {` heads.
366fn item_head(line: &str) -> Option<(ItemKind, String)> {
367    let mut rest = line;
368    if let Some(after) = rest.strip_prefix("pub") {
369        rest = after.trim_start();
370        if rest.starts_with('(') {
371            let close = rest.find(')')?;
372            rest = rest[close + 1..].trim_start();
373        }
374    }
375    for prefix in ["default ", "async "] {
376        if let Some(after) = rest.strip_prefix(prefix) {
377            rest = after.trim_start();
378        }
379    }
380    let (kind, keyword) = if rest.starts_with("struct ") {
381        (ItemKind::Struct, "struct ")
382    } else if rest.starts_with("enum ") {
383        (ItemKind::Enum, "enum ")
384    } else if rest.starts_with("mod ") {
385        (ItemKind::Other, "mod ")
386    } else {
387        let name = read_ident(rest, 0);
388        let after = rest[name.len()..].trim_start();
389        let is_macro = !name.is_empty()
390            && after.starts_with('!')
391            && after[1..].trim_start().starts_with(['{', '(', '[']);
392        return is_macro.then(|| (ItemKind::Macro, name.to_string()));
393    };
394    let name = read_ident(rest[keyword.len()..].trim_start(), 0).to_string();
395    if name.is_empty() {
396        return None;
397    }
398    Some((kind, name))
399}
400
401/// Body of the item whose name ends at `at`, plus the offset just past it.
402fn item_body(src: &str, at: usize) -> (String, usize) {
403    let open = src[at..]
404        .find(['{', '(', '[', ';'])
405        .map(|i| at + i)
406        .unwrap_or(src.len());
407    if open >= src.len() || src[open..].starts_with(';') {
408        return (String::new(), (open + 1).min(src.len()));
409    }
410    match match_delim(src, open) {
411        Some(close) => (src[open + 1..close].to_string(), close + 1),
412        None => (String::new(), src.len()),
413    }
414}
415
416#[derive(Debug, Default)]
417struct ParsedFields {
418    fields: Vec<RustField>,
419    /// Whether the struct flattens another type in — the envelope marker.
420    flattened: bool,
421}
422
423/// Reads named fields out of a struct or struct-variant body.
424fn parse_fields(body: &str, rename_all: Option<String>) -> ParsedFields {
425    let mut out = ParsedFields::default();
426    let mut attrs: Vec<String> = Vec::new();
427    let mut pos = 0usize;
428
429    while pos < body.len() {
430        let line_end = body[pos..]
431            .find('\n')
432            .map(|i| pos + i)
433            .unwrap_or(body.len());
434        let line = body[pos..line_end].trim();
435
436        if line.is_empty() {
437            pos = line_end + 1;
438            continue;
439        }
440        if line.starts_with("#[") {
441            let open = pos + body[pos..line_end].find('[').unwrap_or(0);
442            match match_delim(body, open) {
443                Some(close) => {
444                    attrs.push(body[open + 1..close].trim().to_string());
445                    pos = close + 1;
446                }
447                None => pos = line_end + 1,
448            }
449            continue;
450        }
451
452        let Some((name, type_start)) = field_head(body, pos, line_end) else {
453            attrs.clear();
454            pos = line_end + 1;
455            continue;
456        };
457        let type_end = type_start + find_type_end(&body[type_start..]);
458        let ty = body[type_start..type_end].trim();
459        let args = serde_args(&attrs);
460        pos = type_end + 1;
461
462        if args.iter().any(|a| has_flag(a, "flatten")) {
463            out.flattened = true;
464            attrs.clear();
465            continue;
466        }
467        if args.iter().any(|a| has_flag(a, "skip")) {
468            attrs.clear();
469            continue;
470        }
471        let wire = args
472            .iter()
473            .find_map(|a| arg_value(a, "rename"))
474            .unwrap_or_else(|| match &rename_all {
475                Some(rule) => apply_rename_all(&name, rule),
476                None => name.clone(),
477            });
478        let has_default = args
479            .iter()
480            .any(|a| has_flag(a, "default") || arg_value(a, "default").is_some());
481        out.fields.push(RustField {
482            name: wire,
483            required: !(ty.starts_with("Option<") || has_default),
484        });
485        attrs.clear();
486    }
487    out
488}
489
490/// `pub message_id: MessageId,` -> (`message_id`, offset of `MessageId`).
491fn field_head(body: &str, pos: usize, line_end: usize) -> Option<(String, usize)> {
492    let line = &body[pos..line_end];
493    let trimmed = line.trim_start();
494    let mut offset = pos + (line.len() - trimmed.len());
495    let mut rest = trimmed;
496    if let Some(after) = rest.strip_prefix("pub") {
497        let vis_len = rest.len() - after.len();
498        rest = after.trim_start();
499        offset += vis_len + (after.len() - rest.len());
500        if rest.starts_with('(') {
501            let close = rest.find(')')? + 1;
502            offset += close;
503            let after = rest[close..].trim_start();
504            offset += rest[close..].len() - after.len();
505            rest = after;
506        }
507    }
508    let name = read_ident(rest, 0);
509    if name.is_empty() {
510        return None;
511    }
512    let after_name = rest[name.len()..].trim_start();
513    if !after_name.starts_with(':') || after_name.starts_with("::") {
514        return None;
515    }
516    let colon_at = offset + (rest.len() - after_name.len()) + 1;
517    Some((name.to_string(), colon_at))
518}
519
520/// Length of the type expression at the start of `s`, up to the `,` that ends
521/// the field. Generics, tuples and slices nest.
522fn find_type_end(s: &str) -> usize {
523    let mut depth = 0i32;
524    for (i, c) in s.char_indices() {
525        match c {
526            '<' | '(' | '[' | '{' => depth += 1,
527            '>' | ')' | ']' | '}' => {
528                if depth == 0 {
529                    return i;
530                }
531                depth -= 1;
532            }
533            ',' if depth == 0 => return i,
534            _ => {}
535        }
536    }
537    s.len()
538}
539
540/// One member of the tagged union, wherever it was declared.
541#[derive(Debug)]
542struct UnionMember {
543    /// Wire tag.
544    tag: String,
545    /// The payload type the variant wraps, if it wraps one.
546    payload: Option<String>,
547    /// Fields declared inline on a struct variant. `None` means "look the
548    /// payload type up"; `Some(vec![])` means "this variant has no fields".
549    inline_fields: Option<Vec<RustField>>,
550    /// Repo-relative file the declaration was read from.
551    file: String,
552}
553
554/// Reads the variants of a `#[serde(tag = "type")]` enum.
555fn parse_variants(
556    body: &str,
557    rename_all: Option<&str>,
558    rename_all_fields: Option<&str>,
559    file: &str,
560) -> Vec<UnionMember> {
561    let mut out = Vec::new();
562    let mut attrs: Vec<String> = Vec::new();
563    let mut pos = 0usize;
564
565    while pos < body.len() {
566        let line_end = body[pos..]
567            .find('\n')
568            .map(|i| pos + i)
569            .unwrap_or(body.len());
570        let line = body[pos..line_end].trim();
571
572        if line.is_empty() {
573            pos = line_end + 1;
574            continue;
575        }
576        if line.starts_with("#[") {
577            let open = pos + body[pos..line_end].find('[').unwrap_or(0);
578            match match_delim(body, open) {
579                Some(close) => {
580                    attrs.push(body[open + 1..close].trim().to_string());
581                    pos = close + 1;
582                }
583                None => pos = line_end + 1,
584            }
585            continue;
586        }
587
588        let name = read_ident(line, 0).to_string();
589        if name.is_empty() || !name.starts_with(|c: char| c.is_ascii_uppercase()) {
590            attrs.clear();
591            pos = line_end + 1;
592            continue;
593        }
594
595        let args = serde_args(&attrs);
596        if args.iter().any(|a| has_flag(a, "skip")) {
597            attrs.clear();
598            pos = line_end + 1;
599            continue;
600        }
601        // An explicit rename wins; otherwise apply the container rule to the
602        // variant name. Serde's own default is the bare variant name, but the
603        // AG-UI wire form is always SCREAMING_SNAKE, so that is what an
604        // unannotated variant is read as rather than reported as drift.
605        let tag = args
606            .iter()
607            .find_map(|a| arg_value(a, "rename"))
608            .unwrap_or_else(|| match rename_all {
609                // serde applies `rename_all` to the snake_case form of the name.
610                Some(rule) => {
611                    apply_rename_all(&pascal_to_screaming_snake(&name).to_lowercase(), rule)
612                }
613                None => pascal_to_screaming_snake(&name),
614            });
615
616        let after = line[name.len()..].trim_start();
617        let (payload, inline, next) = if after.starts_with('(') {
618            let open = pos + body[pos..line_end].find('(').unwrap_or(0);
619            match match_delim(body, open) {
620                Some(close) => {
621                    let inner = body[open + 1..close].trim();
622                    let ty = inner.rsplit("::").next().unwrap_or(inner).trim();
623                    let ty = ty.split('<').next().unwrap_or(ty).trim();
624                    let ty = (!ty.is_empty() && !ty.contains(',')).then(|| ty.to_string());
625                    (ty, None, close + 1)
626                }
627                None => (None, None, line_end + 1),
628            }
629        } else if after.starts_with('{') {
630            let open = pos + body[pos..line_end].find('{').unwrap_or(0);
631            match match_delim(body, open) {
632                Some(close) => {
633                    let parsed = parse_fields(
634                        &body[open + 1..close],
635                        rename_all_fields.map(str::to_string),
636                    );
637                    (None, Some(parsed.fields), close + 1)
638                }
639                None => (None, None, line_end + 1),
640            }
641        } else {
642            (None, Some(Vec::new()), line_end + 1)
643        };
644
645        out.push(UnionMember {
646            tag,
647            payload,
648            inline_fields: inline,
649            file: file.to_string(),
650        });
651        attrs.clear();
652        pos = next;
653    }
654    out
655}
656
657/// Reads a macro table that pairs payload types with wire tags, as in
658/// `TextMessageStart(TextMessageStartEvent) => "TEXT_MESSAGE_START",`.
659///
660/// Generating the event enum from such a table is a common way to keep the
661/// union, the discriminator enum and the constructors in one place — and it
662/// hides every tag from a scanner that only reads `enum` bodies. Entries are
663/// only accepted when a tag literal and a payload type appear together, so
664/// unrelated macros carrying SCREAMING_SNAKE strings are not mistaken for
665/// event declarations.
666fn parse_macro_table(body: &str, file: &str) -> Vec<UnionMember> {
667    let mut out = Vec::new();
668    for entry in split_top_level(body, ',') {
669        let entry = strip_attributes(entry);
670        let Some(tag) = screaming_snake_literal(&entry) else {
671            continue;
672        };
673        let Some((variant, payload)) = variant_call(&entry) else {
674            continue;
675        };
676        if !payload.ends_with("Event") && pascal_to_screaming_snake(&variant) != tag {
677            continue;
678        }
679        out.push(UnionMember {
680            tag,
681            payload: Some(payload),
682            inline_fields: None,
683            file: file.to_string(),
684        });
685    }
686    out
687}
688
689/// Removes `#[...]` attributes from a macro-table entry.
690fn strip_attributes(entry: &str) -> String {
691    let mut out = entry.to_string();
692    while let Some(hash) = out.find("#[") {
693        match match_delim(&out, hash + 1) {
694            Some(close) => out.replace_range(hash..=close, ""),
695            None => break,
696        }
697    }
698    out
699}
700
701/// The first `"SCREAMING_SNAKE"` string literal in `entry`.
702fn screaming_snake_literal(entry: &str) -> Option<String> {
703    let mut rest = entry;
704    while let Some(open) = rest.find('"') {
705        let after = &rest[open + 1..];
706        let close = after.find('"')?;
707        let literal = &after[..close];
708        if is_wire_tag(literal) {
709            return Some(literal.to_string());
710        }
711        rest = &after[close + 1..];
712    }
713    None
714}
715
716/// Whether a string has the shape of an AG-UI event tag.
717///
718/// Every one of them is SCREAMING_SNAKE, which is what separates an event tag
719/// from any other string a declaration happens to carry.
720fn is_wire_tag(tag: &str) -> bool {
721    tag.starts_with(|c: char| c.is_ascii_uppercase())
722        && tag
723            .chars()
724            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
725}
726
727/// The first `Variant(PayloadType)` in `entry`, as `(variant, payload)`.
728fn variant_call(entry: &str) -> Option<(String, String)> {
729    let mut at = 0usize;
730    while at < entry.len() {
731        let name = read_ident(entry, at);
732        if !name.is_empty() {
733            let after = at + name.len();
734            if name.starts_with(|c: char| c.is_ascii_uppercase()) && entry[after..].starts_with('(')
735            {
736                if let Some(close) = match_delim(entry, after) {
737                    let inner = entry[after + 1..close].trim();
738                    let payload = inner
739                        .rsplit("::")
740                        .next()
741                        .unwrap_or(inner)
742                        .split('<')
743                        .next()
744                        .unwrap_or(inner)
745                        .trim();
746                    if !payload.is_empty() && !payload.contains(',') {
747                        return Some((name.to_string(), payload.to_string()));
748                    }
749                }
750            }
751            at = after;
752        }
753        at += entry[at..].chars().next().map_or(1, char::len_utf8);
754    }
755    None
756}
757
758/// The argument lists of every `serde(...)` attribute in `attrs`.
759fn serde_args(attrs: &[String]) -> Vec<String> {
760    attrs
761        .iter()
762        .filter_map(|attr| {
763            let rest = attr.trim().strip_prefix("serde")?.trim_start();
764            let close = match_delim(rest, rest.find('(')?)?;
765            Some(rest[rest.find('(')? + 1..close].to_string())
766        })
767        .collect()
768}
769
770/// `rename = "X"` inside a serde argument list.
771fn arg_value(args: &str, key: &str) -> Option<String> {
772    split_top_level(args, ',').into_iter().find_map(|arg| {
773        let (name, value) = arg.split_once('=')?;
774        (name.trim() == key).then(|| value.trim().trim_matches('"').to_string())
775    })
776}
777
778/// A bare `flatten` / `skip` / `default` flag inside a serde argument list.
779fn has_flag(args: &str, key: &str) -> bool {
780    split_top_level(args, ',')
781        .iter()
782        .any(|arg| arg.trim() == key)
783}
784
785fn container_rename_all(attrs: &[String]) -> Option<String> {
786    serde_args(attrs)
787        .iter()
788        .find_map(|arg| arg_value(arg, "rename_all"))
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794
795    fn fields(surface: &RustSurface, tag: &str) -> Vec<RustField> {
796        surface
797            .events
798            .iter()
799            .find(|e| e.tag == tag)
800            .unwrap()
801            .fields
802            .clone()
803            .unwrap()
804    }
805
806    /// Scans one source file, through the real directory walk. Tests run in
807    /// parallel, so each gets its own directory.
808    fn scan_str(src: &str) -> RustSurface {
809        use std::sync::atomic::{AtomicUsize, Ordering};
810        static NEXT: AtomicUsize = AtomicUsize::new(0);
811
812        let dir = std::env::temp_dir().join(format!(
813            "xtask-drift-{}-{}",
814            std::process::id(),
815            NEXT.fetch_add(1, Ordering::Relaxed)
816        ));
817        let _ = std::fs::remove_dir_all(&dir);
818        std::fs::create_dir_all(&dir).unwrap();
819        std::fs::write(dir.join("event.rs"), src).unwrap();
820        let surface = scan(&dir, &dir).unwrap();
821        std::fs::remove_dir_all(&dir).unwrap();
822        surface
823    }
824
825    #[test]
826    fn a_module_with_no_event_types_scans_to_nothing() {
827        // `run()` turns this into a hard error rather than a silent pass: a
828        // scanner that reads zero events must never look like agreement.
829        let surface = scan_str(
830            r#"
831pub struct Helper {
832    pub a: u8,
833}
834
835pub enum Untagged {
836    One,
837}
838"#,
839        );
840        assert!(surface.events.is_empty());
841        assert_eq!(surface.tagged_enum, None);
842        assert_eq!(surface.files.len(), 1);
843    }
844
845    #[test]
846    fn reads_payload_structs() {
847        let surface = scan_str(
848            r#"
849/// Doc comment mentioning struct NotAnItem.
850#[derive(Serialize)]
851#[serde(rename_all = "camelCase")]
852pub struct TextMessageStartEvent {
853    #[serde(flatten)]
854    pub base: BaseEvent,
855    pub message_id: MessageId,
856    #[serde(default)]
857    pub role: TextMessageRole,
858    #[serde(default, skip_serializing_if = "Option::is_none")]
859    pub name: Option<String>,
860    #[serde(skip)]
861    pub internal: u8,
862}
863
864#[derive(Serialize)]
865pub struct BaseEvent {
866    pub timestamp: Option<i64>,
867}
868
869#[cfg(test)]
870mod tests {
871    #[derive(Serialize)]
872    pub struct GhostEvent {
873        #[serde(flatten)]
874        pub base: BaseEvent,
875    }
876}
877"#,
878        );
879        assert_eq!(
880            surface
881                .events
882                .iter()
883                .map(|e| e.tag.clone())
884                .collect::<Vec<_>>(),
885            ["TEXT_MESSAGE_START"]
886        );
887        assert_eq!(
888            fields(&surface, "TEXT_MESSAGE_START"),
889            [
890                RustField {
891                    name: "messageId".into(),
892                    required: true
893                },
894                RustField {
895                    name: "role".into(),
896                    required: false
897                },
898                RustField {
899                    name: "name".into(),
900                    required: false
901                },
902            ]
903        );
904    }
905
906    #[test]
907    fn reads_tagged_enum_with_explicit_renames() {
908        let surface = scan_str(
909            r#"
910#[derive(Serialize)]
911#[serde(tag = "type")]
912pub enum Event {
913    #[serde(rename = "TEXT_MESSAGE_START")]
914    TextMessageStart(TextMessageStartEvent),
915    #[serde(rename = "RUN_ERROR")]
916    RunError(RunErrorEvent),
917}
918
919#[serde(rename_all = "camelCase")]
920pub struct TextMessageStartEvent {
921    #[serde(flatten)]
922    pub base: BaseEvent,
923    pub message_id: String,
924}
925"#,
926        );
927        assert_eq!(surface.tagged_enum.as_deref(), Some("Event"));
928        assert_eq!(
929            surface
930                .events
931                .iter()
932                .map(|e| e.tag.clone())
933                .collect::<Vec<_>>(),
934            ["RUN_ERROR", "TEXT_MESSAGE_START"]
935        );
936        assert_eq!(
937            fields(&surface, "TEXT_MESSAGE_START"),
938            [RustField {
939                name: "messageId".into(),
940                required: true
941            }]
942        );
943        // The payload type for RUN_ERROR is missing; that is a note, not a field
944        // comparison against an empty struct.
945        let run_error = surface
946            .events
947            .iter()
948            .find(|e| e.tag == "RUN_ERROR")
949            .unwrap();
950        assert!(run_error.fields.is_none());
951        assert_eq!(surface.notes.len(), 1);
952    }
953
954    #[test]
955    fn reads_bare_variant_names_and_inline_fields() {
956        let surface = scan_str(
957            r#"
958#[serde(
959    tag = "type",
960    rename_all = "SCREAMING_SNAKE_CASE",
961    rename_all_fields = "camelCase"
962)]
963pub enum Event {
964    StepStarted { step_name: String },
965    Raw { event: Value, source: Option<String> },
966}
967"#,
968        );
969        assert_eq!(
970            surface
971                .events
972                .iter()
973                .map(|e| e.tag.clone())
974                .collect::<Vec<_>>(),
975            ["RAW", "STEP_STARTED"]
976        );
977        assert_eq!(
978            fields(&surface, "STEP_STARTED"),
979            [RustField {
980                name: "stepName".into(),
981                required: true
982            }]
983        );
984        assert_eq!(
985            fields(&surface, "RAW"),
986            [
987                RustField {
988                    name: "event".into(),
989                    required: true
990                },
991                RustField {
992                    name: "source".into(),
993                    required: false
994                },
995            ]
996        );
997    }
998
999    #[test]
1000    fn reads_a_macro_generated_union() {
1001        let surface = scan_str(
1002            r#"
1003macro_rules! define_events {
1004    ($(
1005        $(#[$meta:meta])*
1006        $variant:ident($payload:ty) => $tag:literal,
1007    )*) => {
1008        #[derive(Serialize, Deserialize)]
1009        #[serde(tag = "type")]
1010        pub enum Event {
1011            $(
1012                $(#[$meta])*
1013                #[serde(rename = $tag)]
1014                $variant($payload),
1015            )*
1016        }
1017    };
1018}
1019
1020define_events! {
1021    /// Opens a text message.
1022    TextMessageStart(TextMessageStartEvent) => "TEXT_MESSAGE_START",
1023    #[cfg_attr(not(feature = "utoipa"), deprecated(note = "use Event::ReasoningEnd"))]
1024    ThinkingEnd(ThinkingEndEvent) => "THINKING_END",
1025}
1026
1027#[serde(rename_all = "camelCase")]
1028pub struct TextMessageStartEvent {
1029    #[serde(flatten)]
1030    pub base: BaseEvent,
1031    pub message_id: String,
1032}
1033
1034#[serde(rename_all = "camelCase")]
1035pub struct ThinkingEndEvent {
1036    #[serde(flatten)]
1037    pub base: BaseEvent,
1038}
1039
1040/// Declared but never added to the union.
1041#[serde(rename_all = "camelCase")]
1042pub struct ActivityDeltaEvent {
1043    #[serde(flatten)]
1044    pub base: BaseEvent,
1045    pub patch: Vec<PatchOperation>,
1046}
1047"#,
1048        );
1049        assert_eq!(surface.tagged_enum.as_deref(), Some("Event"));
1050        assert_eq!(
1051            surface
1052                .events
1053                .iter()
1054                .map(|e| e.tag.clone())
1055                .collect::<Vec<_>>(),
1056            ["ACTIVITY_DELTA", "TEXT_MESSAGE_START", "THINKING_END"]
1057        );
1058        assert_eq!(
1059            fields(&surface, "TEXT_MESSAGE_START"),
1060            [RustField {
1061                name: "messageId".into(),
1062                required: true
1063            }]
1064        );
1065        // The macro's own definition must not be mistaken for a declaration,
1066        // and a struct outside the table is flagged by being enum-less.
1067        let union: Vec<&str> = surface
1068            .events
1069            .iter()
1070            .filter(|e| e.from_enum)
1071            .map(|e| e.tag.as_str())
1072            .collect();
1073        assert_eq!(union, ["TEXT_MESSAGE_START", "THINKING_END"]);
1074        assert!(surface.notes.is_empty());
1075    }
1076
1077    /// The envelope is not an event, but its fields are on every event, so it
1078    /// is read on its own rather than skipped with the other non-events.
1079    #[test]
1080    fn reads_the_base_event_envelope() {
1081        let surface = scan_str(
1082            r#"
1083#[derive(Serialize)]
1084#[serde(rename_all = "camelCase")]
1085pub struct BaseEvent {
1086    #[serde(default, skip_serializing_if = "Option::is_none")]
1087    pub timestamp: Option<i64>,
1088    #[serde(default, skip_serializing_if = "Option::is_none")]
1089    pub raw_event: Option<Value>,
1090    #[serde(default, deserialize_with = "reject_null", skip_serializing_if = "Option::is_none")]
1091    pub metadata: Option<JsonObject>,
1092}
1093
1094#[serde(rename_all = "camelCase")]
1095pub struct RawEvent {
1096    #[serde(flatten)]
1097    pub base: BaseEvent,
1098    pub event: Value,
1099}
1100"#,
1101        );
1102        let base = surface.base_event.expect("BaseEvent should have been read");
1103        assert_eq!(
1104            base.fields,
1105            [
1106                RustField {
1107                    name: "timestamp".into(),
1108                    required: false
1109                },
1110                RustField {
1111                    name: "rawEvent".into(),
1112                    required: false
1113                },
1114                RustField {
1115                    name: "metadata".into(),
1116                    required: false
1117                },
1118            ]
1119        );
1120        assert_eq!(base.file, "event.rs");
1121        // ...and it is still not an event type.
1122        assert_eq!(
1123            surface
1124                .events
1125                .iter()
1126                .map(|e| e.tag.clone())
1127                .collect::<Vec<_>>(),
1128            ["RAW"]
1129        );
1130    }
1131
1132    #[test]
1133    fn a_module_without_a_base_event_reports_none_rather_than_empty() {
1134        let surface = scan_str(
1135            r#"
1136#[serde(rename_all = "camelCase")]
1137pub struct RawEvent {
1138    pub event: Value,
1139}
1140"#,
1141        );
1142        assert!(surface.base_event.is_none());
1143    }
1144
1145    /// `type` is a common discriminator. Reading every enum tagged with it as
1146    /// the event union turned `SubagentOutcome`'s `success` and `suspended`
1147    /// into two event types that upstream had never heard of.
1148    #[test]
1149    fn another_type_tagged_enum_is_not_the_event_union() {
1150        let surface = scan_str(
1151            r#"
1152define_events! {
1153    Raw(RawEvent) => "RAW",
1154}
1155
1156#[derive(Serialize)]
1157#[serde(tag = "type")]
1158pub enum Event {
1159    #[serde(rename = "RAW")]
1160    Raw(RawEvent),
1161}
1162
1163#[derive(Serialize)]
1164#[serde(tag = "type", rename_all = "lowercase")]
1165pub enum SubagentOutcome {
1166    Success,
1167    Suspended { interrupt_ids: Option<Vec<String>> },
1168}
1169
1170#[serde(rename_all = "camelCase")]
1171pub struct RawEvent {
1172    #[serde(flatten)]
1173    pub base: BaseEvent,
1174    pub event: Value,
1175}
1176"#,
1177        );
1178        assert_eq!(
1179            surface
1180                .events
1181                .iter()
1182                .map(|e| e.tag.clone())
1183                .collect::<Vec<_>>(),
1184            ["RAW"]
1185        );
1186        assert_eq!(surface.tagged_enum.as_deref(), Some("Event"));
1187        assert!(surface.notes.is_empty(), "{:?}", surface.notes);
1188    }
1189
1190    /// End to end for the lifetime that swallowed a struct: the payload after
1191    /// `&'static str` must still be read, fields and all.
1192    #[test]
1193    fn a_lifetime_before_a_payload_does_not_hide_it() {
1194        let surface = scan_str(
1195            r#"
1196impl TextMessageRole {
1197    pub const fn as_str(&self) -> &'static str {
1198        "assistant"
1199    }
1200}
1201
1202fn null_role_is_the_default<'de, D>(deserializer: D) -> Result<TextMessageRole, D::Error>
1203where
1204    D: Deserializer<'de>,
1205{
1206    Ok(TextMessageRole::Assistant)
1207}
1208
1209/// A doc comment with [`brackets`] and a `{` in it.
1210#[serde(rename_all = "camelCase")]
1211pub struct TextMessageChunkEvent {
1212    #[serde(flatten)]
1213    pub base: BaseEvent,
1214    #[serde(default, skip_serializing_if = "Option::is_none")]
1215    pub message_id: Option<MessageId>,
1216}
1217"#,
1218        );
1219        assert_eq!(
1220            fields(&surface, "TEXT_MESSAGE_CHUNK"),
1221            [RustField {
1222                name: "messageId".into(),
1223                required: false
1224            }]
1225        );
1226    }
1227
1228    #[test]
1229    fn generic_field_types_do_not_split_early() {
1230        let surface = scan_str(
1231            r#"
1232#[serde(rename_all = "camelCase")]
1233pub struct CustomEvent {
1234    #[serde(flatten)]
1235    pub base: BaseEvent,
1236    pub value: BTreeMap<String, Value>,
1237    pub name: String,
1238}
1239"#,
1240        );
1241        assert_eq!(
1242            fields(&surface, "CUSTOM"),
1243            [
1244                RustField {
1245                    name: "value".into(),
1246                    required: true
1247                },
1248                RustField {
1249                    name: "name".into(),
1250                    required: true
1251                },
1252            ]
1253        );
1254    }
1255}