aicx_parser/
frontmatter.rs1use serde::Deserialize;
4
5use crate::timeline::FrameKind;
6
7#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
9pub struct ReportFrontmatter {
10 #[serde(default, flatten)]
11 pub telemetry: ReportFrontmatterTelemetry,
12 #[serde(default, flatten)]
13 pub steering: ReportFrontmatterSteering,
14}
15
16#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
18pub struct ReportFrontmatterTelemetry {
19 pub agent: Option<String>,
20 pub run_id: Option<String>,
21 pub prompt_id: Option<String>,
22 pub status: Option<String>,
23 pub frame_kind: Option<FrameKind>,
24 pub model: Option<String>,
25 pub started_at: Option<String>,
26 pub completed_at: Option<String>,
27 pub token_usage: Option<u64>,
28 pub findings_count: Option<u32>,
29}
30
31#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
33pub struct ReportFrontmatterSteering {
34 #[serde(alias = "phase")]
35 pub workflow_phase: Option<String>,
36 pub mode: Option<String>,
37 #[serde(alias = "skill")]
38 pub skill_code: Option<String>,
39 pub framework_version: Option<String>,
40}
41
42fn split_block(text: &str) -> Option<(&str, &str)> {
43 let trimmed = text.trim_start();
44 if !trimmed.starts_with("---") {
45 return None;
46 }
47
48 let after_open = &trimmed[3..];
49 let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
50
51 let end = after_open.find("\n---")?;
52 let yaml_str = &after_open[..end];
53 let body_start = end + 4; let body = after_open[body_start..]
55 .strip_prefix('\n')
56 .unwrap_or(&after_open[body_start..]);
57
58 Some((yaml_str, body))
59}
60
61pub fn parse(text: &str) -> (Option<ReportFrontmatter>, &str) {
64 let Some((yaml_str, body)) = split_block(text) else {
65 return (None, text);
66 };
67
68 let frontmatter = parse_frontmatter_fields(yaml_str);
69 (frontmatter, body)
70}
71
72fn parse_frontmatter_fields(yaml_str: &str) -> Option<ReportFrontmatter> {
73 let mut parsed = ReportFrontmatter::default();
74 let mut saw_field = false;
75
76 for raw_line in yaml_str.lines() {
77 let line = raw_line.trim();
78 if line.is_empty() || line.starts_with('#') {
79 continue;
80 }
81
82 let (key, value) = line.split_once(':')?;
83 let key = key.trim();
84 if key.is_empty() || key.contains(char::is_whitespace) {
85 return None;
86 }
87
88 let value = value.trim();
89 if looks_like_unsupported_yaml_value(value) {
90 return None;
91 }
92 let value = normalize_scalar(value);
93 saw_field = true;
94
95 match key {
96 "agent" => parsed.telemetry.agent = string_value(value),
97 "run_id" => parsed.telemetry.run_id = string_value(value),
98 "prompt_id" => parsed.telemetry.prompt_id = string_value(value),
99 "status" => parsed.telemetry.status = string_value(value),
100 "frame_kind" => parsed.telemetry.frame_kind = FrameKind::parse(value),
101 "model" => parsed.telemetry.model = string_value(value),
102 "started_at" => parsed.telemetry.started_at = string_value(value),
103 "completed_at" => parsed.telemetry.completed_at = string_value(value),
104 "token_usage" => parsed.telemetry.token_usage = value.parse::<u64>().ok(),
105 "findings_count" => parsed.telemetry.findings_count = value.parse::<u32>().ok(),
106 "workflow_phase" | "phase" => parsed.steering.workflow_phase = string_value(value),
107 "mode" => parsed.steering.mode = string_value(value),
108 "skill_code" | "skill" => parsed.steering.skill_code = string_value(value),
109 "framework_version" => parsed.steering.framework_version = string_value(value),
110 _ => {}
111 }
112 }
113
114 saw_field.then_some(parsed)
115}
116
117fn normalize_scalar(value: &str) -> &str {
118 value.trim().trim_matches(|ch| matches!(ch, '"' | '\''))
119}
120
121fn looks_like_unsupported_yaml_value(value: &str) -> bool {
122 value.starts_with('[') || value.starts_with('{')
123}
124
125fn string_value(value: &str) -> Option<String> {
126 (!value.is_empty()).then(|| value.to_string())
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn parses_valid_frontmatter() {
135 let input = "---\nagent: codex\nrun_id: mrbl-001\nprompt_id: api-redesign_20260327\nstatus: completed\nframe_kind: agent_reply\nphase: implement\nmode: session-first\nskill: vc-workflow\nframework_version: 2026-03\n---\n# Report\nContent here";
136 let (frontmatter, body) = parse(input);
137 let frontmatter = frontmatter.unwrap();
138
139 assert_eq!(frontmatter.telemetry.agent.as_deref(), Some("codex"));
140 assert_eq!(frontmatter.telemetry.run_id.as_deref(), Some("mrbl-001"));
141 assert_eq!(
142 frontmatter.telemetry.prompt_id.as_deref(),
143 Some("api-redesign_20260327")
144 );
145 assert_eq!(frontmatter.telemetry.status.as_deref(), Some("completed"));
146 assert_eq!(
147 frontmatter.telemetry.frame_kind,
148 Some(FrameKind::AgentReply)
149 );
150 assert_eq!(
151 frontmatter.steering.workflow_phase.as_deref(),
152 Some("implement")
153 );
154 assert_eq!(frontmatter.steering.mode.as_deref(), Some("session-first"));
155 assert_eq!(
156 frontmatter.steering.skill_code.as_deref(),
157 Some("vc-workflow")
158 );
159 assert_eq!(
160 frontmatter.steering.framework_version.as_deref(),
161 Some("2026-03")
162 );
163 assert!(body.starts_with("# Report"));
164 }
165
166 #[test]
167 fn returns_none_for_no_frontmatter() {
168 let input = "# Just a report\nNo frontmatter here";
169 let (frontmatter, body) = parse(input);
170
171 assert!(frontmatter.is_none());
172 assert_eq!(body, input);
173 }
174
175 #[test]
176 fn handles_malformed_yaml_gracefully() {
177 let input = "---\n: this is not valid yaml [\n---\nBody";
178 let (frontmatter, body) = parse(input);
179
180 assert!(frontmatter.is_none());
181 assert_eq!(body, "Body");
182 }
183}