1use 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
26pub const BASE_EVENT: &str = "BaseEvent";
29
30const NOT_EVENTS: &[&str] = &[BASE_EVENT, "AnyEvent"];
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RustField {
36 pub name: String,
37 pub required: bool,
38}
39
40#[derive(Debug, Clone)]
42pub struct RustEvent {
43 pub tag: String,
45 pub rust_type: Option<String>,
47 pub file: String,
49 pub fields: Option<Vec<RustField>>,
52 pub from_enum: bool,
53 pub from_struct: bool,
54}
55
56#[derive(Debug, Clone)]
59pub struct RustBaseEvent {
60 pub fields: Vec<RustField>,
62 pub file: String,
64}
65
66#[derive(Debug, Clone, Default)]
68pub struct RustSurface {
69 pub events: Vec<RustEvent>,
71 pub base_event: Option<RustBaseEvent>,
75 pub tagged_enum: Option<String>,
77 pub files: Vec<String>,
79 pub notes: Vec<String>,
81}
82
83pub 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 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 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 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 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 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 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
293fn 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 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
364fn 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
401fn 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 flattened: bool,
421}
422
423fn 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
490fn 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
520fn 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#[derive(Debug)]
542struct UnionMember {
543 tag: String,
545 payload: Option<String>,
547 inline_fields: Option<Vec<RustField>>,
550 file: String,
552}
553
554fn 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 let tag = args
606 .iter()
607 .find_map(|a| arg_value(a, "rename"))
608 .unwrap_or_else(|| match rename_all {
609 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
657fn 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
689fn 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
701fn 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
716fn 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
727fn 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
758fn 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
770fn 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
778fn 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 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 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 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 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 #[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 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 #[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 #[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}