Skip to main content

specfold_core/import/
convert.rs

1//! Converting an old document's frontmatter to the specfold shape.
2
3use super::map::{ImportMap, SourceRule};
4use crate::model::doc::list_of;
5use crate::model::stamp;
6use serde_json::{Map, Value};
7
8pub struct Converted {
9    pub fm: Map<String, Value>,
10    pub status_from: String,
11    pub aliases: Vec<String>,
12    pub unresolved: Vec<String>,
13}
14
15/// A document named in an old relation, without the notes people wrote around it:
16/// `ADR-7-use-files.md (platform clauses only)` becomes `ADR-7-use-files`. A path keeps its
17/// extension, since old paths resolve through the alias table as written.
18fn document_target(raw: &str) -> String {
19    let mut s = raw.trim();
20    if let Some(i) = s.find(" (") {
21        s = &s[..i];
22    }
23    if s.contains('/') {
24        return s.trim().to_string();
25    }
26    s.trim_end_matches(".md").trim().to_string()
27}
28
29pub fn status(map: &ImportMap, rule: &SourceRule, old: &str, allowed: &[String]) -> Option<String> {
30    let mapped = rule.status.get(old).or_else(|| map.status.get(old)).cloned().unwrap_or_else(|| old.to_string());
31    allowed.contains(&mapped).then_some(mapped)
32}
33
34pub fn frontmatter(
35    map: &ImportMap,
36    rule: &SourceRule,
37    old: &Map<String, Value>,
38    allowed: &[String],
39    fallback_title: &str,
40) -> Converted {
41    let mut fm = Map::new();
42    let mut rel = Map::new();
43    let mut unresolved = vec![];
44    let mut aliases: Vec<String> = rule.alias_fields.iter().flat_map(|f| list_of(old.get(f))).collect();
45    let mut fallbacks = vec![];
46    for (k, v) in old {
47        let target = map.keys.get(k).map(String::as_str).unwrap_or(k.as_str());
48        if let Some(t) = target.strip_suffix('?') {
49            fallbacks.push((t, v));
50            continue;
51        }
52        match target {
53            "-" | "id" | "status" | "created" | "updated" | "title" => {}
54            t if t.starts_with("rel.") => {
55                let kind = &t[4..];
56                let list = rel.entry(kind.to_string()).or_insert_with(|| Value::Array(vec![]));
57                if let Value::Array(items) = list {
58                    let clean = |s: String| if kind == "governs" { s } else { document_target(&s) };
59                    items.extend(list_of(Some(v)).into_iter().map(clean).map(Value::String));
60                }
61            }
62            "aliases" => aliases.extend(list_of(Some(v))),
63            "rel" => {
64                if let Value::Object(m) = v {
65                    rel.extend(m.clone());
66                }
67            }
68            t => {
69                fm.insert(t.to_string(), v.clone());
70            }
71        }
72    }
73    for (t, v) in fallbacks {
74        fm.entry(t.to_string()).or_insert_with(|| v.clone());
75    }
76    let status_from = old.get("status").and_then(Value::as_str).unwrap_or("").trim().to_string();
77    let status_new = if status_from.is_empty() {
78        rule.default_status.clone().filter(|s| allowed.contains(s))
79    } else {
80        status(map, rule, &status_from, allowed)
81    };
82    let status_new = status_new.unwrap_or_else(|| {
83        unresolved.push(format!("status '{status_from}' has no mapping"));
84        allowed.first().cloned().unwrap_or_else(|| "draft".into())
85    });
86    let date = |k: &str| {
87        old.get(k)
88            .and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_i64().map(|n| n.to_string())))
89            .and_then(|s| stamp::normalize_date(&s))
90    };
91    let created = date("created").or_else(|| date("date"));
92    let updated = date("updated").or_else(|| created.clone());
93    let title =
94        old.get("title").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| fallback_title.to_string());
95    let mut out = Map::new();
96    out.insert("title".into(), Value::String(title));
97    out.insert("status".into(), Value::String(status_new));
98    if let Some(c) = created {
99        out.insert("created".into(), Value::String(c));
100    }
101    if let Some(u) = updated {
102        out.insert("updated".into(), Value::String(u));
103    }
104    out.extend(fm);
105    if !rel.is_empty() {
106        out.insert("rel".into(), Value::Object(rel));
107    }
108    aliases.sort();
109    aliases.dedup();
110    Converted { fm: out, status_from, aliases, unresolved }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn a_fallback_key_fills_only_what_nothing_else_set() {
119        let map: ImportMap =
120            toml::from_str("[keys]\ncheck_cmd = \"verify\"\ncheck = \"verify?\"\nverify = \"verify_note\"\n").unwrap();
121        let rule: SourceRule = toml::from_str("glob = \"x\"\ntype = \"sot\"").unwrap();
122        let allowed = ["active".to_string()];
123        let convert = |yaml: &str| {
124            let crate::markdown::frontmatter::Parsed::Map(old) = crate::markdown::frontmatter::parse(Some(yaml)) else {
125                panic!("fixture does not parse")
126            };
127            frontmatter(&map, &rule, &old, &allowed, "t").fm
128        };
129        let both = convert("check: manual\ncheck_cmd: cargo test\nverify: prose\nstatus: active\n");
130        assert_eq!((both["verify"].as_str(), both["verify_note"].as_str()), (Some("cargo test"), Some("prose")));
131        let manual = convert("check: manual\nstatus: active\n");
132        assert_eq!(manual["verify"], "manual");
133    }
134
135    #[test]
136    fn relation_targets_lose_notes_and_extensions_but_governs_keeps_paths() {
137        let map: ImportMap =
138            toml::from_str("[keys]\nsupersedes = \"rel.supersedes\"\napplies_to = \"rel.governs\"\n").unwrap();
139        let rule: SourceRule = toml::from_str("glob = \"x\"\ntype = \"adr\"").unwrap();
140        let yaml = "status: active\nsupersedes: [\"ADR-7-use-files.md (platform clauses only)\"]\napplies_to: [\"docs/a.md\"]\n";
141        let crate::markdown::frontmatter::Parsed::Map(old) = crate::markdown::frontmatter::parse(Some(yaml)) else {
142            panic!("fixture does not parse")
143        };
144        let fm = frontmatter(&map, &rule, &old, &["active".to_string()], "t").fm;
145        assert_eq!(fm["rel"]["supersedes"][0], "ADR-7-use-files");
146        assert_eq!(fm["rel"]["governs"][0], "docs/a.md");
147    }
148}