use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use crate::errors::CoreError;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FrontMatter {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub change_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub module_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub integrity: Option<IntegrityMetadata>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, serde_yaml::Value>,
}
impl FrontMatter {
pub fn created_at_dt(&self) -> Option<DateTime<Utc>> {
self.created_at
.as_deref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
}
pub fn updated_at_dt(&self) -> Option<DateTime<Utc>> {
self.updated_at
.as_deref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IntegrityMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body_sha256: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedDocument {
pub front_matter: Option<FrontMatter>,
pub body: String,
}
const DELIMITER: &str = "---";
pub fn parse(content: &str) -> Result<ParsedDocument, CoreError> {
let Some(rest) = content.strip_prefix(DELIMITER) else {
return Ok(ParsedDocument {
front_matter: None,
body: content.to_string(),
});
};
let Some(rest) = rest
.strip_prefix('\n')
.or_else(|| rest.strip_prefix("\r\n"))
else {
return Ok(ParsedDocument {
front_matter: None,
body: content.to_string(),
});
};
let Some(end_pos) = find_closing_delimiter(rest) else {
return Ok(ParsedDocument {
front_matter: None,
body: content.to_string(),
});
};
let yaml_block = &rest[..end_pos];
let body_start = end_pos + DELIMITER.len();
let remaining = &rest[body_start..];
let body = remaining
.strip_prefix('\n')
.or_else(|| remaining.strip_prefix("\r\n"))
.unwrap_or(remaining);
let front_matter: FrontMatter = serde_yaml::from_str(yaml_block)
.map_err(|e| CoreError::Parse(format!("invalid YAML front matter: {e}")))?;
Ok(ParsedDocument {
front_matter: Some(front_matter),
body: body.to_string(),
})
}
fn find_closing_delimiter(text: &str) -> Option<usize> {
let mut pos = 0;
for line in text.lines() {
if line.trim() == DELIMITER {
return Some(pos);
}
pos += line.len();
if text[pos..].starts_with("\r\n") {
pos += 2;
} else if text[pos..].starts_with('\n') {
pos += 1;
}
}
None
}
pub fn write(front_matter: Option<&FrontMatter>, body: &str) -> Result<String, CoreError> {
let Some(fm) = front_matter else {
return Ok(body.to_string());
};
let yaml = serde_yaml::to_string(fm)
.map_err(|e| CoreError::Parse(format!("failed to serialize front matter: {e}")))?;
let yaml = yaml.trim_end();
Ok(format!("{DELIMITER}\n{yaml}\n{DELIMITER}\n{body}"))
}
fn format_timestamp(dt: DateTime<Utc>) -> String {
dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
pub fn touch(front_matter: Option<FrontMatter>, now: DateTime<Utc>) -> FrontMatter {
let ts = format_timestamp(now);
match front_matter {
Some(mut fm) => {
fm.updated_at = Some(ts);
fm
}
None => FrontMatter {
schema_version: Some("1".to_string()),
created_at: Some(ts.clone()),
updated_at: Some(ts),
created_by: None,
updated_by: None,
change_id: None,
module_id: None,
integrity: None,
extra: BTreeMap::new(),
},
}
}
pub fn body_sha256(body: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(body.as_bytes());
hex::encode(hasher.finalize())
}
pub fn update_integrity(front_matter: &mut FrontMatter, body: &str) {
let checksum = body_sha256(body);
match &mut front_matter.integrity {
Some(integrity) => {
integrity.body_sha256 = Some(checksum);
}
None => {
front_matter.integrity = Some(IntegrityMetadata {
body_sha256: Some(checksum),
});
}
}
}
pub fn validate_integrity(front_matter: &FrontMatter, body: &str) -> Result<(), CoreError> {
let Some(integrity) = &front_matter.integrity else {
return Ok(());
};
let Some(expected) = &integrity.body_sha256 else {
return Ok(());
};
let actual = body_sha256(body);
if *expected != actual {
return Err(CoreError::Validation(format!(
"artifact body checksum mismatch: expected {expected}, got {actual}"
)));
}
Ok(())
}
pub fn validate_id(
field_name: &str,
front_matter_value: Option<&str>,
expected: &str,
) -> Result<(), CoreError> {
let Some(actual) = front_matter_value else {
return Ok(());
};
if actual != expected {
return Err(CoreError::Validation(format!(
"{field_name} mismatch in front matter: expected '{expected}', found '{actual}'"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Timelike};
#[test]
fn parse_no_front_matter() {
let content = "# Hello\n\nSome content.";
let result = parse(content).unwrap();
assert!(result.front_matter.is_none());
assert_eq!(result.body, content);
}
#[test]
fn parse_valid_front_matter() {
let content =
"---\nschema_version: \"1\"\ncreated_at: \"2026-01-15T10:00:00Z\"\n---\n# Hello\n";
let result = parse(content).unwrap();
let fm = result.front_matter.unwrap();
assert_eq!(fm.schema_version.as_deref(), Some("1"));
assert_eq!(fm.created_at.as_deref(), Some("2026-01-15T10:00:00Z"));
let dt = fm.created_at_dt().unwrap();
assert_eq!(dt, Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap());
assert_eq!(result.body, "# Hello\n");
}
#[test]
fn parse_empty_front_matter() {
let content = "---\n---\n# Body";
let result = parse(content).unwrap();
let fm = result.front_matter.unwrap();
assert!(fm.schema_version.is_none());
assert_eq!(result.body, "# Body");
}
#[test]
fn parse_no_closing_delimiter() {
let content = "---\nschema_version: 1\n# Not closed";
let result = parse(content).unwrap();
assert!(result.front_matter.is_none());
assert_eq!(result.body, content);
}
#[test]
fn parse_delimiter_with_extra_text_on_first_line() {
let content = "--- extra stuff\nschema_version: 1\n---\nbody";
let result = parse(content).unwrap();
assert!(result.front_matter.is_none());
assert_eq!(result.body, content);
}
#[test]
fn parse_invalid_yaml() {
let content = "---\n: : invalid:\n---\nbody";
let result = parse(content);
assert!(result.is_err());
}
#[test]
fn parse_with_integrity() {
let body = "# Content\n";
let checksum = body_sha256(body);
let content = format!("---\nintegrity:\n body_sha256: {checksum}\n---\n{body}");
let result = parse(&content).unwrap();
let fm = result.front_matter.unwrap();
assert_eq!(
fm.integrity.as_ref().unwrap().body_sha256.as_deref(),
Some(checksum.as_str())
);
assert_eq!(result.body, body);
}
#[test]
fn roundtrip_write_parse() {
let now = Utc.with_ymd_and_hms(2026, 3, 1, 12, 0, 0).unwrap();
let fm = touch(None, now);
let body = "# My proposal\n\nSome text.\n";
let doc = write(Some(&fm), body).unwrap();
let parsed = parse(&doc).unwrap();
let parsed_fm = parsed.front_matter.as_ref().unwrap();
assert_eq!(parsed_fm.created_at_dt(), Some(now));
assert_eq!(parsed.body, body);
}
#[test]
fn write_no_front_matter_returns_body() {
let body = "# Just body\n";
let result = write(None, body).unwrap();
assert_eq!(result, body);
}
#[test]
fn touch_creates_new_front_matter() {
let now = Utc::now();
let fm = touch(None, now);
assert!(fm.created_at.is_some());
assert!(fm.updated_at.is_some());
assert_eq!(fm.created_at, fm.updated_at);
assert_eq!(fm.schema_version.as_deref(), Some("1"));
assert_eq!(fm.created_at_dt(), Some(now.with_nanosecond(0).unwrap()));
}
#[test]
fn touch_updates_existing() {
let t1 = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let t2 = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap();
let fm = touch(None, t1);
let updated = touch(Some(fm), t2);
assert_eq!(updated.created_at_dt(), Some(t1));
assert_eq!(updated.updated_at_dt(), Some(t2));
}
#[test]
fn body_sha256_is_deterministic() {
let body = "# Hello world\n";
let h1 = body_sha256(body);
let h2 = body_sha256(body);
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64);
}
#[test]
fn update_integrity_sets_checksum() {
let mut fm = touch(None, Utc::now());
let body = "Some content\n";
update_integrity(&mut fm, body);
let expected = body_sha256(body);
assert_eq!(
fm.integrity.as_ref().unwrap().body_sha256.as_deref(),
Some(expected.as_str())
);
}
#[test]
fn validate_integrity_passes_when_matching() {
let body = "# Good content\n";
let mut fm = touch(None, Utc::now());
update_integrity(&mut fm, body);
assert!(validate_integrity(&fm, body).is_ok());
}
#[test]
fn validate_integrity_fails_on_mismatch() {
let body = "# Good content\n";
let mut fm = touch(None, Utc::now());
update_integrity(&mut fm, body);
let result = validate_integrity(&fm, "# Tampered content\n");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("checksum mismatch"));
}
#[test]
fn validate_integrity_passes_when_no_checksum() {
let fm = touch(None, Utc::now());
assert!(validate_integrity(&fm, "anything").is_ok());
}
#[test]
fn validate_id_passes_when_absent() {
assert!(validate_id("change_id", None, "024-10").is_ok());
}
#[test]
fn validate_id_passes_when_matching() {
assert!(validate_id("change_id", Some("024-10"), "024-10").is_ok());
}
#[test]
fn validate_id_fails_on_mismatch() {
let result = validate_id("change_id", Some("999-99_bad"), "024-10");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("change_id"));
assert!(msg.contains("mismatch"));
}
#[test]
fn parse_preserves_extra_fields() {
let content = "---\ncustom_field: hello\n---\nbody";
let result = parse(content).unwrap();
let fm = result.front_matter.unwrap();
assert_eq!(
fm.extra.get("custom_field"),
Some(&serde_yaml::Value::String("hello".to_string()))
);
}
#[test]
fn format_timestamp_produces_rfc3339() {
let dt = Utc.with_ymd_and_hms(2026, 3, 1, 12, 30, 45).unwrap();
let ts = format_timestamp(dt);
assert_eq!(ts, "2026-03-01T12:30:45Z");
}
#[test]
fn created_at_dt_returns_none_when_absent() {
let fm = FrontMatter {
schema_version: None,
created_at: None,
updated_at: None,
created_by: None,
updated_by: None,
change_id: None,
module_id: None,
integrity: None,
extra: BTreeMap::new(),
};
assert!(fm.created_at_dt().is_none());
}
#[test]
fn created_at_dt_returns_none_for_invalid_timestamp() {
let fm = FrontMatter {
schema_version: None,
created_at: Some("not-a-date".to_string()),
updated_at: None,
created_by: None,
updated_by: None,
change_id: None,
module_id: None,
integrity: None,
extra: BTreeMap::new(),
};
assert!(fm.created_at_dt().is_none());
}
}