use serde::{Deserialize, Serialize};
#[cfg(feature = "wasm")]
use wasm_bindgen::prelude::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrontmatterMeta {
pub title: String,
pub slug: String,
pub version: u64,
pub state: String,
pub contributors: Vec<String>,
pub content_hash: String,
pub exported_at: String,
}
pub fn canonical_frontmatter(meta: &FrontmatterMeta) -> String {
let mut sorted_contributors = meta.contributors.clone();
sorted_contributors.sort();
let mut out = String::new();
out.push_str("---\n");
out.push_str(&format!("title: \"{}\"\n", escape_yaml_string(&meta.title)));
out.push_str(&format!("slug: \"{}\"\n", escape_yaml_string(&meta.slug)));
out.push_str(&format!("version: {}\n", meta.version));
out.push_str(&format!("state: \"{}\"\n", escape_yaml_string(&meta.state)));
out.push_str("contributors:\n");
for contributor in &sorted_contributors {
out.push_str(&format!(" - \"{}\"\n", escape_yaml_string(contributor)));
}
out.push_str(&format!(
"content_hash: \"{}\"\n",
escape_yaml_string(&meta.content_hash)
));
out.push_str(&format!(
"exported_at: \"{}\"\n",
escape_yaml_string(&meta.exported_at)
));
out.push_str("---\n");
out
}
fn escape_yaml_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = "canonicalFrontmatter"))]
pub fn canonical_frontmatter_wasm(meta_json: &str) -> String {
match serde_json::from_str::<FrontmatterMeta>(meta_json) {
Ok(meta) => canonical_frontmatter(&meta),
Err(e) => format!("ERROR: invalid FrontmatterMeta JSON: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_standard() -> (&'static str, String) {
let meta = FrontmatterMeta {
title: "My Document Title".to_string(),
slug: "my-document-title".to_string(),
version: 3,
state: "APPROVED".to_string(),
contributors: vec!["agent-bob".to_string(), "agent-alice".to_string()],
content_hash: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
.to_string(),
exported_at: "2026-04-17T19:00:00.000Z".to_string(),
};
let expected = concat!(
"---\n",
"title: \"My Document Title\"\n",
"slug: \"my-document-title\"\n",
"version: 3\n",
"state: \"APPROVED\"\n",
"contributors:\n",
" - \"agent-alice\"\n",
" - \"agent-bob\"\n",
"content_hash: \"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"\n",
"exported_at: \"2026-04-17T19:00:00.000Z\"\n",
"---\n",
);
(expected, canonical_frontmatter(&meta))
}
#[test]
fn test_spec_example_byte_identical() {
let (expected, actual) = fixture_standard();
assert_eq!(
actual, expected,
"output must be byte-identical to spec §4.1 example"
);
}
#[test]
fn test_contributors_sorted_regardless_of_input_order() {
let meta = FrontmatterMeta {
title: "Sort Test".to_string(),
slug: "sort-test".to_string(),
version: 1,
state: "DRAFT".to_string(),
contributors: vec![
"zeta-agent".to_string(),
"alpha-agent".to_string(),
"beta-agent".to_string(),
],
content_hash: "abc".to_string(),
exported_at: "2026-01-01T00:00:00.000Z".to_string(),
};
let output = canonical_frontmatter(&meta);
let contrib_start = output
.find("contributors:\n")
.expect("contributors key must appear");
let contrib_section = &output[contrib_start..];
let mut lines = contrib_section
.lines()
.skip(1) .take(3) .collect::<Vec<_>>();
let names: Vec<&str> = lines
.iter_mut()
.map(|l| l.trim_start_matches(" - ").trim_matches('"'))
.collect();
assert_eq!(names, ["alpha-agent", "beta-agent", "zeta-agent"]);
}
#[test]
fn test_empty_contributors() {
let meta = FrontmatterMeta {
title: "No Contributors".to_string(),
slug: "no-contributors".to_string(),
version: 1,
state: "DRAFT".to_string(),
contributors: vec![],
content_hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
.to_string(),
exported_at: "2026-04-17T00:00:00.000Z".to_string(),
};
let output = canonical_frontmatter(&meta);
let expected = concat!(
"---\n",
"title: \"No Contributors\"\n",
"slug: \"no-contributors\"\n",
"version: 1\n",
"state: \"DRAFT\"\n",
"contributors:\n",
"content_hash: \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n",
"exported_at: \"2026-04-17T00:00:00.000Z\"\n",
"---\n",
);
assert_eq!(output, expected);
}
#[test]
fn test_lf_line_endings_and_single_trailing_newline() {
let (_, output) = fixture_standard();
assert!(!output.contains("\r\n"), "output must use LF only, no CRLF");
assert!(
output.ends_with('\n'),
"output must end with a trailing newline"
);
assert!(
!output.ends_with("\n\n"),
"output must end with exactly one newline, not two"
);
}
#[test]
fn test_special_characters_in_title_escaped() {
let meta = FrontmatterMeta {
title: "Doc with \"quotes\" and \\backslash".to_string(),
slug: "doc-with-quotes".to_string(),
version: 2,
state: "REVIEW".to_string(),
contributors: vec!["agent-x".to_string()],
content_hash: "deadbeef".to_string(),
exported_at: "2026-04-17T12:00:00.000Z".to_string(),
};
let output = canonical_frontmatter(&meta);
assert!(
output.contains(r#"title: "Doc with \"quotes\" and \\backslash""#),
"title must have escaped quotes and backslashes; got:\n{output}"
);
}
#[test]
fn test_version_zero() {
let meta = FrontmatterMeta {
title: "Initial".to_string(),
slug: "initial".to_string(),
version: 0,
state: "DRAFT".to_string(),
contributors: vec!["agent-a".to_string()],
content_hash: "0000".to_string(),
exported_at: "2026-04-17T00:00:00.000Z".to_string(),
};
let output = canonical_frontmatter(&meta);
assert!(output.contains("version: 0\n"));
}
#[test]
fn test_wasm_binding_round_trip() {
let json = r#"{
"title": "WASM Test",
"slug": "wasm-test",
"version": 1,
"state": "DRAFT",
"contributors": ["carol", "alice", "bob"],
"content_hash": "abcdef",
"exported_at": "2026-04-17T10:00:00.000Z"
}"#;
let output = canonical_frontmatter_wasm(json);
let alice_pos = output.find("alice").expect("alice must appear");
let bob_pos = output.find("bob").expect("bob must appear");
let carol_pos = output.find("carol").expect("carol must appear");
assert!(alice_pos < bob_pos && bob_pos < carol_pos);
assert!(!output.starts_with("ERROR:"));
}
#[test]
fn test_wasm_binding_invalid_json_returns_error_prefix() {
let output = canonical_frontmatter_wasm("{not valid json}");
assert!(
output.starts_with("ERROR:"),
"invalid JSON must return ERROR prefix; got: {output}"
);
}
}