use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum Sessions {
List(Vec<String>),
Structured {
#[serde(skip_serializing_if = "Option::is_none")]
origin: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
work: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
updated: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SpecMilestoneEntry {
pub version: String,
pub name: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SpecFrontmatter {
#[serde(default)]
pub r#type: String,
#[serde(default)]
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocked_by: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocks: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sessions: Option<Sessions>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub related: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub beliefs: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub references: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub milestones: Vec<SpecMilestoneEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_milestone: Option<String>,
}
pub fn parse_spec_file(content: &str) -> Result<(SpecFrontmatter, String)> {
let content = content
.strip_prefix("---")
.ok_or_else(|| anyhow::anyhow!("Spec file must start with '---' frontmatter delimiter"))?;
let end = content.find("\n---").ok_or_else(|| {
anyhow::anyhow!("Spec file must have closing '---' frontmatter delimiter")
})?;
let frontmatter_str = &content[..end];
let body = &content[end + 4..];
let frontmatter: SpecFrontmatter = serde_yaml::from_str(frontmatter_str)
.with_context(|| format!("Failed to parse frontmatter:\n{}", frontmatter_str))?;
Ok((frontmatter, body.to_string()))
}
pub fn serialize_spec_file(frontmatter: &SpecFrontmatter, body: &str) -> Result<String> {
let yaml = serde_yaml::to_string(frontmatter)?;
Ok(format!("---\n{}---{}", yaml, body))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_roundtrip() {
let content = r#"---
type: feat
id: test-spec
status: ready
target: v0.12.0
blocked_by:
- other-spec
blocks: []
---
# Test Spec
Body content here.
"#;
let (frontmatter, body) = parse_spec_file(content).expect("should parse");
assert_eq!(frontmatter.id, "test-spec");
assert_eq!(frontmatter.status, Some("ready".to_string()));
assert_eq!(frontmatter.target, Some("v0.12.0".to_string()));
assert_eq!(frontmatter.blocked_by, vec!["other-spec"]);
assert!(body.contains("# Test Spec"));
let output = serialize_spec_file(&frontmatter, &body).expect("should serialize");
let (fm2, _) = parse_spec_file(&output).expect("should re-parse");
assert_eq!(fm2.id, frontmatter.id);
assert_eq!(fm2.status, frontmatter.status);
}
#[test]
fn test_optional_fields() {
let content = r#"---
type: explore
id: minimal
---
# Minimal spec
"#;
let (frontmatter, _) = parse_spec_file(content).expect("should parse minimal");
assert_eq!(frontmatter.id, "minimal");
assert_eq!(frontmatter.status, None);
assert!(frontmatter.blocked_by.is_empty());
}
}