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    /// Foreign-import provenance (operator-md and similar). Structural, so it
30    /// reaches the sidecar instead of living as body text (Round II / oś 3+5).
31    pub source_file: Option<String>,
32    pub source_format: Option<String>,
33    /// Stable content-hash identity for the imported source, so re-importing the
34    /// same material (even moved/renamed) does not create a fresh record.
35    pub import_id: Option<String>,
36}
37
38/// Small, stable steering metadata that can route retrieval and framework behavior.
39#[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; // skip "\n---"
61    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
68/// Split markdown text into optional frontmatter and body.
69/// Returns `(Some(frontmatter), body)` if frontmatter exists, else `(None, full text)`.
70pub 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}