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            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}