use serde::Deserialize;
use crate::types::FrameKind;
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct ReportFrontmatter {
#[serde(default, flatten)]
pub telemetry: ReportFrontmatterTelemetry,
#[serde(default, flatten)]
pub steering: ReportFrontmatterSteering,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct ReportFrontmatterTelemetry {
pub agent: Option<String>,
pub run_id: Option<String>,
pub prompt_id: Option<String>,
pub status: Option<String>,
pub frame_kind: Option<FrameKind>,
pub model: Option<String>,
pub started_at: Option<String>,
pub completed_at: Option<String>,
pub token_usage: Option<u64>,
pub findings_count: Option<u32>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct ReportFrontmatterSteering {
#[serde(alias = "phase")]
pub workflow_phase: Option<String>,
pub mode: Option<String>,
#[serde(alias = "skill")]
pub skill_code: Option<String>,
pub framework_version: Option<String>,
}
fn split_block(text: &str) -> Option<(&str, &str)> {
let trimmed = text.trim_start();
if !trimmed.starts_with("---") {
return None;
}
let after_open = &trimmed[3..];
let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
let end = after_open.find("\n---")?;
let yaml_str = &after_open[..end];
let body_start = end + 4; let body = after_open[body_start..]
.strip_prefix('\n')
.unwrap_or(&after_open[body_start..]);
Some((yaml_str, body))
}
pub fn parse(text: &str) -> (Option<ReportFrontmatter>, &str) {
let Some((yaml_str, body)) = split_block(text) else {
return (None, text);
};
let frontmatter = serde_yaml::from_str::<ReportFrontmatter>(yaml_str).ok();
(frontmatter, body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_valid_frontmatter() {
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";
let (frontmatter, body) = parse(input);
let frontmatter = frontmatter.unwrap();
assert_eq!(frontmatter.telemetry.agent.as_deref(), Some("codex"));
assert_eq!(frontmatter.telemetry.run_id.as_deref(), Some("mrbl-001"));
assert_eq!(
frontmatter.telemetry.prompt_id.as_deref(),
Some("api-redesign_20260327")
);
assert_eq!(frontmatter.telemetry.status.as_deref(), Some("completed"));
assert_eq!(
frontmatter.telemetry.frame_kind,
Some(FrameKind::AgentReply)
);
assert_eq!(
frontmatter.steering.workflow_phase.as_deref(),
Some("implement")
);
assert_eq!(frontmatter.steering.mode.as_deref(), Some("session-first"));
assert_eq!(
frontmatter.steering.skill_code.as_deref(),
Some("vc-workflow")
);
assert_eq!(
frontmatter.steering.framework_version.as_deref(),
Some("2026-03")
);
assert!(body.starts_with("# Report"));
}
#[test]
fn returns_none_for_no_frontmatter() {
let input = "# Just a report\nNo frontmatter here";
let (frontmatter, body) = parse(input);
assert!(frontmatter.is_none());
assert_eq!(body, input);
}
#[test]
fn handles_malformed_yaml_gracefully() {
let input = "---\n: this is not valid yaml [\n---\nBody";
let (frontmatter, body) = parse(input);
assert!(frontmatter.is_none());
assert_eq!(body, "Body");
}
}