Skip to main content

aicx_parser/
frontmatter.rs

1//! YAML frontmatter parser for markdown reports.
2
3use serde::Deserialize;
4
5use crate::timeline::FrameKind;
6
7/// Parsed frontmatter fields from an agent report.
8#[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/// Passive report telemetry preserved for downstream analytics and correlation.
17#[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/// Small, stable steering metadata that can route retrieval and framework behavior.
32#[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; // skip "\n---"
54    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
61/// Split markdown text into optional frontmatter and body.
62/// Returns `(Some(frontmatter), body)` if frontmatter exists, else `(None, full text)`.
63pub 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            continue;
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 = "---\nagent: codex\nrun_id: mrbl-001\nunsupported: [not, scalar]\nstatus: completed\n---\nBody";
178        let (frontmatter, body) = parse(input);
179        let frontmatter = frontmatter.unwrap();
180
181        assert_eq!(frontmatter.telemetry.agent.as_deref(), Some("codex"));
182        assert_eq!(frontmatter.telemetry.run_id.as_deref(), Some("mrbl-001"));
183        assert_eq!(frontmatter.telemetry.status.as_deref(), Some("completed"));
184        assert_eq!(body, "Body");
185    }
186
187    #[test]
188    fn preserves_scalars_when_value_is_unsupported_yaml_list() {
189        let input = "---\nrun_id: abc123\nmode: [research, audit]\nstatus: completed\nmodel: claude-opus-4-7\nstarted_at: 2026-05-20T11:51:55Z\n---\nBody";
190        let (frontmatter, body) = parse(input);
191        let frontmatter = frontmatter.unwrap();
192
193        assert_eq!(frontmatter.telemetry.run_id.as_deref(), Some("abc123"));
194        assert_eq!(frontmatter.telemetry.status.as_deref(), Some("completed"));
195        assert_eq!(
196            frontmatter.telemetry.model.as_deref(),
197            Some("claude-opus-4-7")
198        );
199        assert_eq!(
200            frontmatter.telemetry.started_at.as_deref(),
201            Some("2026-05-20T11:51:55Z")
202        );
203        assert_eq!(frontmatter.steering.mode, None);
204        assert_eq!(body, "Body");
205    }
206
207    #[test]
208    fn preserves_scalars_when_value_is_unsupported_yaml_map() {
209        let input = "---\nrun_id: abc123\nagent_model: {provider}/claude-opus-4-7\nstatus: completed\nmodel: codex\n---\nBody";
210        let (frontmatter, body) = parse(input);
211        let frontmatter = frontmatter.unwrap();
212
213        assert_eq!(frontmatter.telemetry.run_id.as_deref(), Some("abc123"));
214        assert_eq!(frontmatter.telemetry.status.as_deref(), Some("completed"));
215        assert_eq!(frontmatter.telemetry.model.as_deref(), Some("codex"));
216        assert_eq!(body, "Body");
217    }
218
219    #[test]
220    fn still_returns_none_when_no_delimiters() {
221        let input = "run_id: abc123\nstatus: completed\nBody";
222        let (frontmatter, body) = parse(input);
223
224        assert!(frontmatter.is_none());
225        assert_eq!(body, input);
226    }
227}