use crate::engine::fs::FileSystem;
use anyhow::{anyhow, Result};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::{Path, PathBuf};
pub(crate) fn deserialize_naive_date<'de, D>(
deserializer: D,
) -> std::result::Result<NaiveDate, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_yaml::Value::deserialize(deserializer)?;
let date_str = match &value {
serde_yaml::Value::String(s) => s.clone(),
serde_yaml::Value::Tagged(tagged) => match &tagged.value {
serde_yaml::Value::String(s) => s.clone(),
_ => {
return Err(serde::de::Error::custom(format!(
"expected date string, got: {:?}",
value
)))
}
},
serde_yaml::Value::Mapping(m) if m.len() == 1 => {
let key = m.keys().next().unwrap();
match key {
serde_yaml::Value::String(s) => s.clone(),
_ => {
return Err(serde::de::Error::custom(format!(
"expected date string, got: {:?}",
value
)))
}
}
}
_ => {
return Err(serde::de::Error::custom(format!(
"expected date string, got: {:?}",
value
)))
}
};
NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(serde::de::Error::custom)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub struct DocType(String);
impl DocType {
pub const RFC: &str = "rfc";
pub const STORY: &str = "story";
pub const ITERATION: &str = "iteration";
pub const ADR: &str = "adr";
pub const SPEC: &str = "spec";
pub fn new(s: &str) -> Self {
DocType(s.to_lowercase())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for DocType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl<'de> Deserialize<'de> for DocType {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(DocType(s.to_lowercase()))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Draft,
Review,
Accepted,
#[serde(rename = "in-progress")]
InProgress,
Complete,
Rejected,
Superseded,
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Status::Draft => write!(f, "draft"),
Status::Review => write!(f, "review"),
Status::Accepted => write!(f, "accepted"),
Status::InProgress => write!(f, "in-progress"),
Status::Complete => write!(f, "complete"),
Status::Rejected => write!(f, "rejected"),
Status::Superseded => write!(f, "superseded"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelationType {
Implements,
Supersedes,
Blocks,
RelatedTo,
}
impl RelationType {
pub const ALL: [RelationType; 4] = [
RelationType::Implements,
RelationType::Supersedes,
RelationType::Blocks,
RelationType::RelatedTo,
];
pub const ALL_STRS: [&str; 4] = ["implements", "supersedes", "blocks", "related-to"];
}
impl fmt::Display for RelationType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RelationType::Implements => write!(f, "implements"),
RelationType::Supersedes => write!(f, "supersedes"),
RelationType::Blocks => write!(f, "blocks"),
RelationType::RelatedTo => write!(f, "related-to"),
}
}
}
impl std::str::FromStr for DocType {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(DocType::new(s))
}
}
impl std::str::FromStr for Status {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"draft" => Ok(Status::Draft),
"review" => Ok(Status::Review),
"accepted" => Ok(Status::Accepted),
"in-progress" => Ok(Status::InProgress),
"complete" => Ok(Status::Complete),
"rejected" => Ok(Status::Rejected),
"superseded" => Ok(Status::Superseded),
_ => Err(anyhow!("unknown status: {}", s)),
}
}
}
impl std::str::FromStr for RelationType {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"implements" => Ok(RelationType::Implements),
"supersedes" => Ok(RelationType::Supersedes),
"blocks" => Ok(RelationType::Blocks),
"related-to" | "related to" => Ok(RelationType::RelatedTo),
_ => Err(anyhow!("unknown relation type: {}", s)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Relation {
pub rel_type: RelationType,
pub target: String,
}
#[derive(Debug, Clone)]
pub struct DocMeta {
pub path: PathBuf,
pub title: String,
pub doc_type: DocType,
pub status: Status,
pub author: String,
pub date: NaiveDate,
pub tags: Vec<String>,
pub provenance: Vec<String>,
pub related: Vec<Relation>,
pub validate_ignore: bool,
pub virtual_doc: bool,
pub id: String,
}
#[derive(Deserialize)]
struct RawFrontmatter {
title: String,
#[serde(rename = "type")]
doc_type: DocType,
status: Status,
author: String,
#[serde(deserialize_with = "deserialize_naive_date")]
date: NaiveDate,
tags: Vec<String>,
#[serde(default)]
provenance: Vec<String>,
#[serde(default)]
related: Vec<serde_yaml::Value>,
#[serde(default, rename = "validate-ignore")]
validate_ignore: bool,
}
pub fn rewrite_frontmatter<F>(path: &Path, fs: &dyn FileSystem, mutate: F) -> Result<()>
where
F: FnOnce(&mut serde_yaml::Value) -> Result<()>,
{
let content = fs.read_to_string(path)?;
let (yaml, body) = split_frontmatter(&content)?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&yaml)?;
mutate(&mut value)?;
let new_yaml = serde_yaml::to_string(&value)?;
let output = compose_frontmatter(&new_yaml, &body);
fs.write(path, &output)?;
Ok(())
}
pub fn compose_frontmatter(yaml: &str, body: &str) -> String {
if yaml.ends_with('\n') {
format!("---\n{}---{}", yaml, body)
} else {
format!("---\n{}\n---{}", yaml, body)
}
}
pub fn split_frontmatter(content: &str) -> Result<(String, String)> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return Err(anyhow!("no frontmatter found"));
}
let after_first = &trimmed[3..];
let end = after_first
.find("\n---")
.ok_or_else(|| anyhow!("no closing frontmatter delimiter"))?;
let frontmatter = after_first[..end].trim().to_string();
let body = after_first[end + 4..].to_string();
Ok((frontmatter, body))
}
pub(crate) fn parse_relation(value: &serde_yaml::Value) -> Result<Relation> {
let map = value
.as_mapping()
.ok_or_else(|| anyhow!("relation entry must be a mapping"))?;
let (key, val) = map
.iter()
.next()
.ok_or_else(|| anyhow!("relation mapping is empty"))?;
let key_str = key
.as_str()
.ok_or_else(|| anyhow!("relation key must be a string"))?;
let rel_type: RelationType = key_str.parse()?;
let target = val
.as_str()
.ok_or_else(|| anyhow!("relation target must be a string"))?
.to_string();
Ok(Relation { rel_type, target })
}
impl DocMeta {
pub fn parse(content: &str) -> Result<Self> {
let (frontmatter, _) = split_frontmatter(content)?;
let raw: RawFrontmatter = serde_yaml::from_str(&frontmatter)?;
for entry in &raw.provenance {
if entry.is_empty() {
return Err(anyhow!(
"provenance entry must not be empty (title: {})",
raw.title
));
}
}
let related = raw
.related
.iter()
.map(parse_relation)
.collect::<Result<Vec<_>>>()?;
Ok(DocMeta {
path: PathBuf::new(),
title: raw.title,
doc_type: raw.doc_type,
status: raw.status,
author: raw.author,
date: raw.date,
tags: raw.tags,
provenance: raw.provenance,
related,
validate_ignore: raw.validate_ignore,
virtual_doc: false,
id: String::new(),
})
}
pub fn extract_body(content: &str) -> Result<String> {
let (_, body) = split_frontmatter(content)?;
Ok(body.trim_start_matches('\n').to_string())
}
pub fn display_name(&self) -> &str {
&self.id
}
pub fn sort_by_date(a: &DocMeta, b: &DocMeta) -> std::cmp::Ordering {
a.date.cmp(&b.date).then_with(|| a.path.cmp(&b.path))
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
fn make_doc(date: &str, path: &str) -> DocMeta {
DocMeta {
path: PathBuf::from(path),
title: String::new(),
doc_type: DocType::new("rfc"),
status: Status::Draft,
author: String::new(),
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
tags: vec![],
provenance: vec![],
related: vec![],
validate_ignore: false,
virtual_doc: false,
id: String::new(),
}
}
#[test]
fn sort_by_date_oldest_first() {
let old = make_doc("2025-01-01", "a.md");
let new = make_doc("2026-03-17", "b.md");
let mut docs = [new, old];
docs.sort_by(DocMeta::sort_by_date);
assert_eq!(docs[0].date, NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
assert_eq!(docs[1].date, NaiveDate::from_ymd_opt(2026, 3, 17).unwrap());
}
#[test]
fn sort_by_date_same_date_tiebreak_by_path() {
let a = make_doc("2026-01-01", "aaa.md");
let b = make_doc("2026-01-01", "zzz.md");
let mut docs = [b, a];
docs.sort_by(DocMeta::sort_by_date);
assert_eq!(docs[0].path, PathBuf::from("aaa.md"));
assert_eq!(docs[1].path, PathBuf::from("zzz.md"));
}
#[test]
fn sort_by_date_single_and_empty() {
let mut empty: Vec<DocMeta> = vec![];
empty.sort_by(DocMeta::sort_by_date);
assert!(empty.is_empty());
let mut single = [make_doc("2026-01-01", "only.md")];
single.sort_by(DocMeta::sort_by_date);
assert_eq!(single.len(), 1);
}
#[test]
fn provenance_loads_in_order() {
let content = r#"---
title: "Doc"
type: rfc
status: draft
author: a
date: 2026-01-01
tags: []
provenance:
- "Workshop 2026-04-12"
- "Jane Doe"
- "Privacy Act 1988"
---
Body.
"#;
let meta = DocMeta::parse(content).unwrap();
assert_eq!(
meta.provenance,
vec![
"Workshop 2026-04-12".to_string(),
"Jane Doe".to_string(),
"Privacy Act 1988".to_string(),
]
);
}
#[test]
fn provenance_missing_defaults_empty() {
let content = r#"---
title: "Doc"
type: rfc
status: draft
author: a
date: 2026-01-01
tags: []
---
Body.
"#;
let meta = DocMeta::parse(content).unwrap();
assert!(meta.provenance.is_empty());
}
#[test]
fn provenance_empty_list_loads() {
let content = r#"---
title: "Doc"
type: rfc
status: draft
author: a
date: 2026-01-01
tags: []
provenance: []
---
Body.
"#;
let meta = DocMeta::parse(content).unwrap();
assert!(meta.provenance.is_empty());
}
#[test]
fn split_compose_roundtrip_preserves_content() {
let cases = [
"---\ntitle: foo\n---\nbody\n",
"---\ntitle: foo\n---\n\nbody with blank line\n",
"---\ntitle: foo\n---\n",
"---\ntitle: foo\n---",
"---\ntitle: foo\n---\nbody without trailing newline",
];
for original in cases {
let (yaml, body) = split_frontmatter(original).unwrap();
let yaml_with_newline = format!("{}\n", yaml);
let recomposed = compose_frontmatter(&yaml_with_newline, &body);
assert_eq!(recomposed, original, "roundtrip failed for: {:?}", original);
}
}
#[test]
fn rewrite_frontmatter_is_idempotent() {
use crate::engine::fs::FileSystem;
use std::cell::RefCell;
use std::collections::HashMap;
struct InMemFs(RefCell<HashMap<PathBuf, String>>);
impl FileSystem for InMemFs {
fn read_to_string(&self, p: &Path) -> Result<String> {
self.0
.borrow()
.get(p)
.cloned()
.ok_or_else(|| anyhow!("not found: {}", p.display()))
}
fn write(&self, p: &Path, c: &str) -> Result<()> {
self.0.borrow_mut().insert(p.to_path_buf(), c.to_string());
Ok(())
}
fn rename(&self, _: &Path, _: &Path) -> Result<()> {
Ok(())
}
fn read_dir(&self, _: &Path) -> Result<Vec<PathBuf>> {
Ok(vec![])
}
fn exists(&self, p: &Path) -> bool {
self.0.borrow().contains_key(p)
}
fn create_dir_all(&self, _: &Path) -> Result<()> {
Ok(())
}
fn is_dir(&self, _: &Path) -> bool {
false
}
}
let initial = "---\ntitle: foo\n---\nbody\n";
let path = PathBuf::from("doc.md");
let mut map = HashMap::new();
map.insert(path.clone(), initial.to_string());
let fs = InMemFs(RefCell::new(map));
rewrite_frontmatter(&path, &fs, |_| Ok(())).unwrap();
let after_first = fs.read_to_string(&path).unwrap();
for _ in 0..5 {
rewrite_frontmatter(&path, &fs, |_| Ok(())).unwrap();
}
let after_many = fs.read_to_string(&path).unwrap();
assert_eq!(
after_first, after_many,
"no-op rewrite must not accumulate newlines across runs"
);
}
#[test]
fn provenance_empty_string_rejected() {
let content = r#"---
title: "Doc"
type: rfc
status: draft
author: a
date: 2026-01-01
tags: []
provenance:
- ""
- "ok"
---
Body.
"#;
let err = DocMeta::parse(content).unwrap_err();
assert!(
err.to_string().to_lowercase().contains("empty"),
"expected error to mention empty, got: {}",
err
);
}
}