1use 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 pub source_file: Option<String>,
32 pub source_format: Option<String>,
33 pub import_id: Option<String>,
36}
37
38#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
40pub struct ReportFrontmatterSteering {
41 #[serde(alias = "phase")]
42 pub workflow_phase: Option<String>,
43 pub mode: Option<String>,
44 #[serde(alias = "skill")]
45 pub skill_code: Option<String>,
46 pub framework_version: Option<String>,
47}
48
49fn split_block(text: &str) -> Option<(&str, &str)> {
50 let trimmed = text.trim_start();
51 if !trimmed.starts_with("---") {
52 return None;
53 }
54
55 let after_open = &trimmed[3..];
56 let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
57
58 let end = after_open.find("\n---")?;
59 let yaml_str = &after_open[..end];
60 let body_start = end + 4; let body = after_open[body_start..]
62 .strip_prefix('\n')
63 .unwrap_or(&after_open[body_start..]);
64
65 Some((yaml_str, body))
66}
67
68pub fn parse(text: &str) -> (Option<ReportFrontmatter>, &str) {
71 let Some((yaml_str, body)) = split_block(text) else {
72 return (None, text);
73 };
74
75 let frontmatter = parse_frontmatter_fields(yaml_str);
76 (frontmatter, body)
77}
78
79fn parse_frontmatter_fields(yaml_str: &str) -> Option<ReportFrontmatter> {
80 let mut parsed = ReportFrontmatter::default();
81 let mut saw_field = false;
82
83 for raw_line in yaml_str.lines() {
84 let line = raw_line.trim();
85 if line.is_empty() || line.starts_with('#') {
86 continue;
87 }
88
89 let (key, value) = line.split_once(':')?;
90 let key = key.trim();
91 if key.is_empty() || key.contains(char::is_whitespace) {
92 return None;
93 }
94
95 let value = value.trim();
96 if looks_like_unsupported_yaml_value(value) {
97 continue;
98 }
99 let value = normalize_scalar(value);
100 saw_field = true;
101
102 match key {
103 "agent" => parsed.telemetry.agent = string_value(value),
104 "run_id" => parsed.telemetry.run_id = string_value(value),
105 "prompt_id" => parsed.telemetry.prompt_id = string_value(value),
106 "status" => parsed.telemetry.status = string_value(value),
107 "frame_kind" => parsed.telemetry.frame_kind = FrameKind::parse(value),
108 "model" => parsed.telemetry.model = string_value(value),
109 "started_at" => parsed.telemetry.started_at = string_value(value),
110 "completed_at" => parsed.telemetry.completed_at = string_value(value),
111 "token_usage" => parsed.telemetry.token_usage = value.parse::<u64>().ok(),
112 "findings_count" => parsed.telemetry.findings_count = value.parse::<u32>().ok(),
113 "source_file" => parsed.telemetry.source_file = string_value(value),
114 "source_format" => parsed.telemetry.source_format = string_value(value),
115 "import_id" => parsed.telemetry.import_id = string_value(value),
116 "workflow_phase" | "phase" => parsed.steering.workflow_phase = string_value(value),
117 "mode" => parsed.steering.mode = string_value(value),
118 "skill_code" | "skill" => parsed.steering.skill_code = string_value(value),
119 "framework_version" => parsed.steering.framework_version = string_value(value),
120 _ => {}
121 }
122 }
123
124 saw_field.then_some(parsed)
125}
126
127fn normalize_scalar(value: &str) -> &str {
128 value.trim().trim_matches(|ch| matches!(ch, '"' | '\''))
129}
130
131fn looks_like_unsupported_yaml_value(value: &str) -> bool {
132 value.starts_with('[') || value.starts_with('{')
133}
134
135fn string_value(value: &str) -> Option<String> {
136 (!value.is_empty()).then(|| value.to_string())
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn parses_valid_frontmatter() {
145 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";
146 let (frontmatter, body) = parse(input);
147 let frontmatter = frontmatter.unwrap();
148
149 assert_eq!(frontmatter.telemetry.agent.as_deref(), Some("codex"));
150 assert_eq!(frontmatter.telemetry.run_id.as_deref(), Some("mrbl-001"));
151 assert_eq!(
152 frontmatter.telemetry.prompt_id.as_deref(),
153 Some("api-redesign_20260327")
154 );
155 assert_eq!(frontmatter.telemetry.status.as_deref(), Some("completed"));
156 assert_eq!(
157 frontmatter.telemetry.frame_kind,
158 Some(FrameKind::AgentReply)
159 );
160 assert_eq!(
161 frontmatter.steering.workflow_phase.as_deref(),
162 Some("implement")
163 );
164 assert_eq!(frontmatter.steering.mode.as_deref(), Some("session-first"));
165 assert_eq!(
166 frontmatter.steering.skill_code.as_deref(),
167 Some("vc-workflow")
168 );
169 assert_eq!(
170 frontmatter.steering.framework_version.as_deref(),
171 Some("2026-03")
172 );
173 assert!(body.starts_with("# Report"));
174 }
175
176 #[test]
177 fn returns_none_for_no_frontmatter() {
178 let input = "# Just a report\nNo frontmatter here";
179 let (frontmatter, body) = parse(input);
180
181 assert!(frontmatter.is_none());
182 assert_eq!(body, input);
183 }
184
185 #[test]
186 fn handles_malformed_yaml_gracefully() {
187 let input = "---\nagent: codex\nrun_id: mrbl-001\nunsupported: [not, scalar]\nstatus: completed\n---\nBody";
188 let (frontmatter, body) = parse(input);
189 let frontmatter = frontmatter.unwrap();
190
191 assert_eq!(frontmatter.telemetry.agent.as_deref(), Some("codex"));
192 assert_eq!(frontmatter.telemetry.run_id.as_deref(), Some("mrbl-001"));
193 assert_eq!(frontmatter.telemetry.status.as_deref(), Some("completed"));
194 assert_eq!(body, "Body");
195 }
196
197 #[test]
198 fn preserves_scalars_when_value_is_unsupported_yaml_list() {
199 let input = "---\nrun_id: abc123\nmode: [research, audit]\nstatus: completed\nmodel: claude-opus-4-7\nstarted_at: 2026-05-20T11:51:55Z\n---\nBody";
200 let (frontmatter, body) = parse(input);
201 let frontmatter = frontmatter.unwrap();
202
203 assert_eq!(frontmatter.telemetry.run_id.as_deref(), Some("abc123"));
204 assert_eq!(frontmatter.telemetry.status.as_deref(), Some("completed"));
205 assert_eq!(
206 frontmatter.telemetry.model.as_deref(),
207 Some("claude-opus-4-7")
208 );
209 assert_eq!(
210 frontmatter.telemetry.started_at.as_deref(),
211 Some("2026-05-20T11:51:55Z")
212 );
213 assert_eq!(frontmatter.steering.mode, None);
214 assert_eq!(body, "Body");
215 }
216
217 #[test]
218 fn preserves_scalars_when_value_is_unsupported_yaml_map() {
219 let input = "---\nrun_id: abc123\nagent_model: {provider}/claude-opus-4-7\nstatus: completed\nmodel: codex\n---\nBody";
220 let (frontmatter, body) = parse(input);
221 let frontmatter = frontmatter.unwrap();
222
223 assert_eq!(frontmatter.telemetry.run_id.as_deref(), Some("abc123"));
224 assert_eq!(frontmatter.telemetry.status.as_deref(), Some("completed"));
225 assert_eq!(frontmatter.telemetry.model.as_deref(), Some("codex"));
226 assert_eq!(body, "Body");
227 }
228
229 #[test]
230 fn still_returns_none_when_no_delimiters() {
231 let input = "run_id: abc123\nstatus: completed\nBody";
232 let (frontmatter, body) = parse(input);
233
234 assert!(frontmatter.is_none());
235 assert_eq!(body, input);
236 }
237}