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    /// For review, not a decision: what a status pattern read.
14    pub notes: Vec<String>,
15    /// The replacing record a status pattern's `by` group named, as written.
16    pub replaced_by: Option<String>,
17}
18
19/// A document named in an old relation, without the notes people wrote around it:
20/// `ADR-7-use-files.md (platform clauses only)` becomes `ADR-7-use-files`. A path keeps its
21/// extension, since old paths resolve through the alias table as written.
22fn document_target(raw: &str) -> String {
23    let mut s = raw.trim();
24    if let Some(i) = s.find(" (") {
25        s = &s[..i];
26    }
27    if s.contains('/') {
28        return s.trim().to_string();
29    }
30    s.trim_end_matches(".md").trim().to_string()
31}
32
33/// A status matched by a `[[status_patterns]]` entry, and the replacing record its `by` group
34/// names.
35fn by_pattern(map: &ImportMap, old: &str, allowed: &[String]) -> Option<(String, Option<String>)> {
36    map.status_patterns.iter().find_map(|p| {
37        let re = regex::Regex::new(&format!("(?i){}", p.pattern)).ok()?;
38        let caps = re.captures(old.trim())?;
39        allowed.contains(&p.status).then(|| (p.status.clone(), caps.name("by").map(|m| m.as_str().trim().to_string())))
40    })
41}
42
43pub fn status(map: &ImportMap, rule: &SourceRule, old: &str, allowed: &[String]) -> Option<String> {
44    let lower = old.to_lowercase();
45    let mapped = rule
46        .status
47        .get(old)
48        .or_else(|| map.status.get(old))
49        .or_else(|| rule.status.get(&lower))
50        .or_else(|| map.status.get(&lower))
51        .cloned()
52        .unwrap_or_else(|| if allowed.contains(&lower) { lower.clone() } else { old.to_string() });
53    allowed.contains(&mapped).then_some(mapped)
54}
55
56pub fn frontmatter(
57    map: &ImportMap,
58    rule: &SourceRule,
59    old: &Map<String, Value>,
60    allowed: &[String],
61    fallback_title: &str,
62) -> Converted {
63    let mut fm = Map::new();
64    let mut rel = Map::new();
65    let mut unresolved = vec![];
66    let notes = vec![];
67    let mut replaced_by = None;
68    let mut aliases: Vec<String> = rule.alias_fields.iter().flat_map(|f| list_of(old.get(f))).collect();
69    let mut fallbacks = vec![];
70    // An unmapped `date` becomes `created` (below), so it is not kept as a key of its own.
71    let date_is_created = !map.keys.contains_key("date")
72        && !old.contains_key("created")
73        && old
74            .get("date")
75            .and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_i64().map(|n| n.to_string())))
76            .and_then(|s| stamp::normalize_date(&s))
77            .is_some();
78    for (k, v) in old {
79        if k == "date" && date_is_created {
80            continue;
81        }
82        let target = map.keys.get(k).map(String::as_str).unwrap_or(k.as_str());
83        if let Some(t) = target.strip_suffix('?') {
84            fallbacks.push((t, v));
85            continue;
86        }
87        match target {
88            "-" | "id" | "status" | "created" | "updated" | "title" => {}
89            t if t.starts_with("rel.") => {
90                let kind = &t[4..];
91                let list = rel.entry(kind.to_string()).or_insert_with(|| Value::Array(vec![]));
92                if let Value::Array(items) = list {
93                    let clean = |s: String| if kind == "governs" { s } else { document_target(&s) };
94                    items.extend(list_of(Some(v)).into_iter().map(clean).map(Value::String));
95                }
96            }
97            "aliases" => aliases.extend(list_of(Some(v))),
98            "rel" => {
99                if let Value::Object(m) = v {
100                    rel.extend(m.clone());
101                }
102            }
103            t => {
104                fm.insert(t.to_string(), v.clone());
105            }
106        }
107    }
108    for (t, v) in fallbacks {
109        fm.entry(t.to_string()).or_insert_with(|| v.clone());
110    }
111    let status_from = old.get("status").and_then(Value::as_str).unwrap_or("").trim().to_string();
112    let status_new = if status_from.is_empty() {
113        rule.default_status.clone().filter(|s| allowed.contains(s))
114    } else {
115        status(map, rule, &status_from, allowed).or_else(|| {
116            let (st, by) = by_pattern(map, &status_from, allowed)?;
117            replaced_by = by.filter(|b| !b.is_empty());
118            Some(st)
119        })
120    };
121    let status_new = status_new.unwrap_or_else(|| {
122        unresolved.push(format!("status '{status_from}' has no mapping"));
123        allowed.first().cloned().unwrap_or_else(|| "draft".into())
124    });
125    let date = |k: &str| {
126        old.get(k)
127            .and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_i64().map(|n| n.to_string())))
128            .and_then(|s| stamp::normalize_date(&s))
129    };
130    let created = date("created").or_else(|| date("date"));
131    let updated = date("updated").or_else(|| created.clone());
132    let title =
133        old.get("title").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| fallback_title.to_string());
134    let mut out = Map::new();
135    out.insert("title".into(), Value::String(title));
136    out.insert("status".into(), Value::String(status_new));
137    if let Some(c) = created {
138        out.insert("created".into(), Value::String(c));
139    }
140    if let Some(u) = updated {
141        out.insert("updated".into(), Value::String(u));
142    }
143    out.extend(fm);
144    if !rel.is_empty() {
145        out.insert("rel".into(), Value::Object(rel));
146    }
147    aliases.sort();
148    aliases.dedup();
149    Converted { fm: out, status_from, aliases, unresolved, notes, replaced_by }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn a_fallback_key_fills_only_what_nothing_else_set() {
158        let map: ImportMap =
159            toml::from_str("[keys]\ncheck_cmd = \"verify\"\ncheck = \"verify?\"\nverify = \"verify_note\"\n").unwrap();
160        let rule: SourceRule = toml::from_str("glob = \"x\"\ntype = \"sot\"").unwrap();
161        let allowed = ["active".to_string()];
162        let convert = |yaml: &str| {
163            let crate::markdown::frontmatter::Parsed::Map(old) = crate::markdown::frontmatter::parse(Some(yaml)) else {
164                panic!("fixture does not parse")
165            };
166            frontmatter(&map, &rule, &old, &allowed, "t").fm
167        };
168        let both = convert("check: manual\ncheck_cmd: cargo test\nverify: prose\nstatus: active\n");
169        assert_eq!((both["verify"].as_str(), both["verify_note"].as_str()), (Some("cargo test"), Some("prose")));
170        let manual = convert("check: manual\nstatus: active\n");
171        assert_eq!(manual["verify"], "manual");
172    }
173
174    #[test]
175    fn an_unmapped_date_becomes_created_only() {
176        let map: ImportMap = toml::from_str("").unwrap();
177        let rule: SourceRule = toml::from_str("glob = \"x\"\ntype = \"adr\"").unwrap();
178        let crate::markdown::frontmatter::Parsed::Map(old) =
179            crate::markdown::frontmatter::parse(Some("status: active\ndate: 2024-06-04\n"))
180        else {
181            panic!("fixture does not parse")
182        };
183        let fm = frontmatter(&map, &rule, &old, &["active".to_string()], "t").fm;
184        assert_eq!(fm["created"], "2024-06-04");
185        assert!(!fm.contains_key("date"), "{fm:?}");
186    }
187
188    #[test]
189    fn relation_targets_lose_notes_and_extensions_but_governs_keeps_paths() {
190        let map: ImportMap =
191            toml::from_str("[keys]\nsupersedes = \"rel.supersedes\"\napplies_to = \"rel.governs\"\n").unwrap();
192        let rule: SourceRule = toml::from_str("glob = \"x\"\ntype = \"adr\"").unwrap();
193        let yaml = "status: active\nsupersedes: [\"ADR-7-use-files.md (platform clauses only)\"]\napplies_to: [\"docs/a.md\"]\n";
194        let crate::markdown::frontmatter::Parsed::Map(old) = crate::markdown::frontmatter::parse(Some(yaml)) else {
195            panic!("fixture does not parse")
196        };
197        let fm = frontmatter(&map, &rule, &old, &["active".to_string()], "t").fm;
198        assert_eq!(fm["rel"]["supersedes"][0], "ADR-7-use-files");
199        assert_eq!(fm["rel"]["governs"][0], "docs/a.md");
200    }
201}