specfold_core/import/
convert.rs1use 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 pub notes: Vec<String>,
15 pub replaced_by: Option<String>,
17}
18
19fn 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
33fn 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 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}