Skip to main content

specfold_core/import/
plan.rs

1//! Builds the import plan: one row per source for review, and the file changes that apply it.
2
3use super::convert;
4use super::key;
5use super::map::ImportMap;
6use super::owner;
7use super::sources::{self, Unit};
8use crate::authoring::Plan;
9use crate::corpus::Corpus;
10use crate::files::hash;
11use crate::markdown::{body, frontmatter};
12use crate::model::doctype::{Form, KeyKind};
13use crate::model::{DocId, Tier, stamp};
14use crate::placement::paths;
15use anyhow::{Result, anyhow};
16use regex::Regex;
17use serde::Serialize;
18use serde_json::{Map, Value, json};
19use std::collections::{BTreeMap, BTreeSet};
20
21mod links;
22use links::{canonical_links, link_replacements, rewrite_links, without_section};
23
24#[derive(Debug, Clone, Default, Serialize)]
25pub struct Row {
26    pub source: String,
27    /// `doc` or `redirect`.
28    pub kind: String,
29    pub id: String,
30    pub path: String,
31    pub owner_reason: String,
32    pub status_from: String,
33    pub status_to: String,
34    pub aliases: Vec<String>,
35    pub unresolved: Vec<String>,
36    #[serde(skip_serializing_if = "Vec::is_empty")]
37    pub notes: Vec<String>,
38    /// What a status pattern named as the replacing record, resolved after every row is built.
39    #[serde(skip)]
40    pub replaced_by: Option<String>,
41    /// The old Markdown file, whose relative links are rewritten to the new paths.
42    #[serde(skip)]
43    pub old_main: String,
44}
45
46#[derive(Debug, Default, Serialize)]
47pub struct ImportPlan {
48    pub rows: Vec<Row>,
49    #[serde(skip)]
50    pub plan: Plan,
51    pub alias_rows: Vec<(String, String)>,
52    /// Attachments over the repository limit: (bundle path, sha256) to upload to the store.
53    pub uploads: Vec<(String, String)>,
54}
55
56fn name_of(p: &str) -> &str {
57    p.rsplit('/').next().unwrap_or(p)
58}
59
60/// A file's name without `.md`, or a directory's name.
61fn stem_of(p: &str) -> &str {
62    name_of(p).trim_end_matches(".md")
63}
64
65pub fn build(c: &Corpus, map: &ImportMap) -> Result<ImportPlan> {
66    let units = sources::expand(&c.cfg.root, map);
67    let mut out = ImportPlan::default();
68    let mut taken: BTreeSet<String> = c.docs.iter().map(|d| d.id.to_string()).collect();
69    let mut by_source: BTreeMap<String, String> = BTreeMap::new();
70    let mut redirects = vec![];
71    for u in &units {
72        let rule = &map.sources[u.rule];
73        if let Some(re) = &rule.redirect {
74            redirects.push((u.clone(), Regex::new(re).map_err(|e| anyhow!("bad redirect regex: {e}"))?));
75            continue;
76        }
77        if rule.drop {
78            out.plan.remove(u.source.clone());
79            out.rows.push(Row { source: u.source.clone(), kind: "drop".into(), ..Row::default() });
80            continue;
81        }
82        let row = doc_row(c, map, u, &mut taken, &mut out)?;
83        for p in [&u.main, &u.source].into_iter().chain(&u.chapters).chain(&u.attachments) {
84            by_source.insert(p.clone(), row.id.clone());
85        }
86        if map.stem_aliases {
87            out.alias_rows.push((stem_of(&u.source).to_string(), row.id.clone()));
88        }
89        out.rows.push(row);
90    }
91    for (u, re) in redirects {
92        let text = std::fs::read_to_string(c.cfg.root.join(&u.main)).unwrap_or_default();
93        let target = re
94            .captures(&text)
95            .and_then(|cap| cap.name("target"))
96            .map(|m| m.as_str().trim().trim_start_matches("./").to_string());
97        let resolved = target.as_ref().and_then(|t| {
98            by_source.get(t).cloned().or_else(|| c.docs.iter().find(|d| &d.path == t).map(|d| d.id.to_string()))
99        });
100        let mut unresolved = vec![];
101        match &resolved {
102            Some(id) => {
103                out.alias_rows.push((u.source.clone(), id.clone()));
104                if map.stem_aliases {
105                    out.alias_rows.push((stem_of(&u.source).to_string(), id.clone()));
106                }
107            }
108            None => unresolved.push(format!("stub target {} is not imported", target.unwrap_or_else(|| "?".into()))),
109        }
110        out.plan.remove(u.source.clone());
111        out.rows.push(Row {
112            source: u.source.clone(),
113            kind: "redirect".into(),
114            id: resolved.unwrap_or_default(),
115            path: String::new(),
116            owner_reason: String::new(),
117            status_from: String::new(),
118            status_to: String::new(),
119            aliases: vec![],
120            unresolved,
121            ..Row::default()
122        });
123    }
124    canonical_links(&mut out, &by_source);
125    link_replacements(&mut out);
126    rewrite_links(&mut out, &by_source);
127    Ok(out)
128}
129
130/// Candidate names for a `by` text: link targets (`[ADR-0002](0002-use-y.md)` → `0002-use-y.md`,
131/// `0002-use-y`), the text itself, and its trailing number (`ADR-0002` → `0002`).
132fn by_candidates(by: &str) -> Vec<String> {
133    static LINK: std::sync::LazyLock<Regex> =
134        std::sync::LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\(([^)\s]+)\)").expect("static regex is valid"));
135    let mut out = vec![];
136    let mut texts = vec![];
137    for c in LINK.captures_iter(by) {
138        let t = c[2].trim_start_matches("./").to_string();
139        out.push(stem_of(&t).to_string());
140        out.push(t);
141        texts.push(c[1].to_string());
142    }
143    let plain = LINK.replace_all(by, "").trim().to_string();
144    texts.push(plain);
145    for t in texts {
146        let t = t.trim().trim_end_matches(['.', ',']).to_string();
147        if t.is_empty() {
148            continue;
149        }
150        let digits: String =
151            t.chars().rev().take_while(char::is_ascii_digit).collect::<Vec<_>>().into_iter().rev().collect();
152        out.push(t);
153        if !digits.is_empty() {
154            out.push(digits);
155        }
156    }
157    out
158}
159
160fn doc_row(c: &Corpus, map: &ImportMap, u: &Unit, taken: &mut BTreeSet<String>, out: &mut ImportPlan) -> Result<Row> {
161    let rule = &map.sources[u.rule];
162    let ty_name = rule.ty.clone().ok_or_else(|| anyhow!("source {} needs a type", rule.glob))?;
163    let ty = c.cfg.registry.get(&ty_name).ok_or_else(|| anyhow!("unknown type {ty_name}"))?;
164    // A wrapped unit has no Markdown of its own; its document is generated around the files.
165    let generated = u.main.is_empty();
166    let text = if generated { String::new() } else { std::fs::read_to_string(c.cfg.root.join(&u.main))? };
167    let split = frontmatter::split(&text);
168    let mut unresolved = vec![];
169    let old = match frontmatter::parse(split.raw) {
170        frontmatter::Parsed::Map(m) => m,
171        frontmatter::Parsed::None => Map::new(),
172        frontmatter::Parsed::Invalid(e) => {
173            unresolved.push(format!("old frontmatter does not parse: {e}"));
174            Map::new()
175        }
176    };
177    let mut old = old;
178    let body_links = if generated { vec![] } else { merge_body_meta(split.body, map, &mut old, &mut unresolved) };
179    if ty.fields.iter().any(|f| f == "decision_makers") {
180        let body = if generated { "" } else { split.body };
181        super::body_meta::people(&mut old, body);
182    }
183    let scanned = body::scan(split.body, split.body_line, &body::path_cite_re(c.cfg.spec_dir()));
184    let name = name_of(&u.source).trim_end_matches(".md");
185    let name_re = rule.name.as_deref().map(Regex::new).transpose().map_err(|e| anyhow!("bad name regex: {e}"))?;
186    let caps = name_re.as_ref().and_then(|r| r.captures(name_of(&u.source)));
187    let named = caps.as_ref().and_then(|c| c.name("slug")).map(|m| m.as_str()).unwrap_or(name);
188    let heading = scanned.first_heading.clone().map(|h| if map.strip_title_number { strip_number(&h) } else { h });
189    let fallback_title = heading.unwrap_or_else(|| named.replace(['-', '_'], " "));
190    let mut conv = convert::frontmatter(map, rule, &old, &ty.statuses, &fallback_title);
191    unresolved.append(&mut conv.unresolved);
192    add_body_links(&mut conv.fm, body_links);
193    let title = conv.fm.get("title").and_then(Value::as_str).unwrap_or(&fallback_title).to_string();
194    let created = conv.fm.get("created").and_then(Value::as_str).map(str::to_string);
195    let src =
196        key::Source { path: &u.main, caps: caps.as_ref(), created: created.as_deref(), title: &title, name_stem: name };
197    let (k, slug) = key::choose(&c.cfg.root, rule, ty, &src);
198    let governs: Vec<String> = conv
199        .fm
200        .get("rel")
201        .and_then(|r| r.get("governs"))
202        .map(|v| crate::model::doc::list_of(Some(v)))
203        .unwrap_or_default();
204    let (owner, owner_reason) = match rule.owner.as_str() {
205        "auto" => {
206            let home = c.owners.storage_owner(c.owners.containing(&u.source));
207            owner::choose(&c.owners, &governs, &text, home)
208        }
209        fixed => {
210            let o = c.owners.by_id(fixed).ok_or_else(|| anyhow!("source {}: no owner '{fixed}'", rule.glob))?;
211            (o, format!("fixed to {fixed} by the import map"))
212        }
213    };
214    let k = unique_key(ty, &owner.id, k, taken);
215    let slug = if ty.key == KeyKind::Slug { k.clone() } else { slug };
216    let id = DocId::new(&ty.name, &owner.id, &k);
217    taken.insert(id.to_string());
218    let bundle = !u.chapters.is_empty() || !u.attachments.is_empty() || ty.form == Form::Dir;
219    let dir = paths::type_dir(&c.cfg, &c.owners, owner, Tier::Committed, ty);
220    let main = paths::main_file(&dir, ty, &paths::stem(ty, &k, &slug), bundle);
221    let bundle_dir = main.rsplit_once('/').map(|x| x.0.to_string()).unwrap_or_default();
222    let base = if u.source == u.main { u.main.trim_end_matches(".md").to_string() } else { u.source.clone() };
223    let files = move_files(c, u, &base, &bundle_dir, out)?;
224    let mut fm = conv.fm;
225    fm.insert("id".into(), Value::String(id.to_string()));
226    fm.entry("created").or_insert_with(|| Value::String(stamp::normalize_date(&k).unwrap_or_else(stamp::today)));
227    let created_v = fm["created"].clone();
228    let updated = fm.entry("updated").or_insert(created_v.clone());
229    // Old records sometimes date an edit before the file existed; the later date wins.
230    if updated.as_str() < created_v.as_str() {
231        *updated = created_v;
232    }
233    let mut aliases = conv.aliases;
234    if let Some(a) = caps.as_ref().and_then(|c| c.name("alias")) {
235        aliases.push(a.as_str().to_string());
236    }
237    if !aliases.is_empty() {
238        fm.insert("aliases".into(), json!(aliases));
239    }
240    if !files.is_empty() {
241        fm.insert("files".into(), Value::Array(files));
242    }
243    let mut notes = conv.notes;
244    let mut body = if generated { format!("\n# {title}\n") } else { split.body.to_string() };
245    if let Some(name) = map.body.status.as_deref().and_then(|h| h.strip_prefix("section:"))
246        && let Some(rest) = without_section(&body, name)
247    {
248        if map.body.drop_status_section {
249            body = rest;
250        } else {
251            notes.push(format!(
252                "the body keeps '## {name}' next to frontmatter status; [body] drop_status_section = true removes it"
253            ));
254        }
255    }
256    notes.extend(body_notes(map, &body, generated, scanned.first_heading.as_deref(), named, &slug, &id));
257    let content = format!("{}{}", frontmatter::render(&crate::markdown::frontmatter::edit::ordered(fm)), body);
258    crate::authoring::forbid::refuse(c, &main, crate::authoring::forbid::NO_LOCAL_HINT)?;
259    out.plan.write(main.clone(), one_ending(content), true);
260    out.plan.remove(u.source.clone());
261    // Old paths of chapters and attachments name the document too, so their citations resolve.
262    for old in std::iter::once(u.source.clone())
263        .chain((u.main != u.source).then(|| u.main.clone()))
264        .chain(u.chapters.iter().cloned())
265        .chain(u.attachments.iter().cloned())
266        .chain(aliases.iter().cloned())
267    {
268        out.alias_rows.push((old, id.to_string()));
269    }
270    Ok(Row {
271        source: u.source.clone(),
272        kind: "doc".into(),
273        id: id.to_string(),
274        path: main,
275        owner_reason,
276        status_from: conv.status_from,
277        status_to: fm_status(&out.plan, &id.to_string()),
278        aliases,
279        unresolved,
280        notes,
281        replaced_by: conv.replaced_by,
282        old_main: if generated { String::new() } else { u.main.clone() },
283    })
284}
285
286/// Fills status and date from the body where the frontmatter lacks them; returns the body's typed links.
287fn merge_body_meta(
288    body: &str,
289    map: &ImportMap,
290    old: &mut Map<String, Value>,
291    unresolved: &mut Vec<String>,
292) -> Vec<(String, String)> {
293    let found = super::body_meta::scan(body, &map.body);
294    // A gap only matters where the frontmatter has no value either.
295    let has_status = old.contains_key("status");
296    let has_date = old.contains_key("date") || old.contains_key("created");
297    if let Some(st) = found.status
298        && !old.contains_key("status")
299    {
300        old.insert("status".into(), Value::String(st));
301    }
302    if let Some(d) = found.date
303        && !old.contains_key("date")
304        && !old.contains_key("created")
305    {
306        old.insert("date".into(), Value::String(d));
307    }
308    unresolved.extend(found.missed.into_iter().filter(|m| {
309        !(has_status && m.starts_with("no status in the body") || has_date && m.starts_with("no date in the body"))
310    }));
311    found.links
312}
313
314/// Adds links found in the body to `rel`, once each.
315fn add_body_links(fm: &mut Map<String, Value>, body_links: Vec<(String, String)>) {
316    if body_links.is_empty() {
317        return;
318    }
319    let rel = fm.entry("rel").or_insert_with(|| Value::Object(Map::new()));
320    if let Value::Object(rel) = rel {
321        for (kind, target) in body_links {
322            let name = target.rsplit('/').next().unwrap_or(&target).trim_end_matches(".md").to_string();
323            if let Value::Array(items) = rel.entry(kind).or_insert_with(|| Value::Array(vec![]))
324                && !items.iter().any(|v| v.as_str() == Some(&name))
325            {
326                items.push(Value::String(name));
327            }
328        }
329    }
330}
331
332/// The first key after `k` whose id is not taken yet.
333fn unique_key(ty: &crate::model::doctype::TypeDef, owner: &str, mut k: String, taken: &BTreeSet<String>) -> String {
334    loop {
335        let id = DocId::new(&ty.name, owner, &k).to_string();
336        if !taken.contains(&id) {
337            break;
338        }
339        k = match ty.key {
340            KeyKind::Stamp => stamp::next_stamp(&k).unwrap_or(k),
341            KeyKind::Slug => format!("{k}-2"),
342        };
343    }
344    k
345}
346
347/// Plans the move of a unit's chapters and attachments into the bundle; returns the `files` entries.
348fn move_files(c: &Corpus, u: &Unit, base: &str, bundle_dir: &str, out: &mut ImportPlan) -> Result<Vec<Value>> {
349    let mut files = vec![];
350    // Chapters and attachments move: a sibling directory is not the unit's source, so removing
351    // the source alone would leave them behind.
352    for ch in &u.chapters {
353        let rel = ch.strip_prefix(&format!("{base}/")).unwrap_or(name_of(ch));
354        out.plan.copy(ch.clone(), format!("{bundle_dir}/{}", rel.replace('/', "-")));
355        out.plan.remove(ch.clone());
356    }
357    for a in &u.attachments {
358        let rel = a.strip_prefix(&format!("{base}/")).unwrap_or(name_of(a)).to_string();
359        let (sha, bytes) = hash::file(&c.cfg.root.join(a))?;
360        let store = if bytes > c.cfg.raw.files.inline_max_bytes { "store" } else { "repo" };
361        if store == "store" {
362            out.uploads.push((format!("{bundle_dir}/{rel}"), sha.clone()));
363        }
364        out.plan.copy(a.clone(), format!("{bundle_dir}/{rel}"));
365        out.plan.remove(a.clone());
366        files.push(json!({"path": rel, "sha256": sha, "bytes": bytes, "store": store}));
367    }
368    Ok(files)
369}
370
371/// Notes on what the imported body still repeats and on a slug that lost its words.
372fn body_notes(
373    map: &ImportMap,
374    body: &str,
375    generated: bool,
376    heading: Option<&str>,
377    named: &str,
378    slug: &str,
379    id: &DocId,
380) -> Vec<String> {
381    let mut notes = vec![];
382    // C118: say what the body still repeats, rather than rewrite prose the user wrote.
383    if !generated {
384        let kept = super::body_meta::copied_bullets(body);
385        if !kept.is_empty() {
386            notes.push(format!(
387                "the body keeps {} next to the same values in frontmatter; delete those lines so they cannot drift",
388                kept.iter().map(|k| format!("'{k}:'")).collect::<Vec<_>>().join(", ")
389            ));
390        }
391        if let Some(h) = heading
392            && map.strip_title_number
393            && strip_number(h) != h
394        {
395            notes.push(format!("the title drops the number, but the body's heading keeps '# {h}'"));
396        }
397    }
398    // As with `spec new`: a mostly non-ASCII name loses its words from the slug.
399    let alnum = named.chars().filter(|c| c.is_alphanumeric() && !c.is_ascii_digit()).count();
400    let ascii = named.chars().filter(|c| c.is_ascii_alphabetic()).count();
401    if alnum > 0 && ascii * 2 < alnum {
402        notes.push(format!("the name is mostly non-ASCII, so the slug is '{slug}' (the title stays); to rename: spec mv {} --slug <slug> --write", crate::shell::word(id)));
403    }
404    notes
405}
406
407/// `2. Use X` → `Use X`; a title that is only a number stays.
408fn strip_number(title: &str) -> String {
409    let digits = title.chars().take_while(char::is_ascii_digit).count();
410    match title[digits..].strip_prefix('.') {
411        Some(rest) if digits > 0 && !rest.trim().is_empty() => rest.trim().to_string(),
412        _ => title.to_string(),
413    }
414}
415
416fn fm_status(plan: &Plan, _id: &str) -> String {
417    plan.changes
418        .iter()
419        .rev()
420        .find_map(|c| match c {
421            crate::authoring::Change::Write { content, .. } => {
422                content.lines().find_map(|l| l.strip_prefix("status: ").map(str::to_string))
423            }
424            _ => None,
425        })
426        .unwrap_or_default()
427}
428
429/// Appends the alias rows to the table file (creating it), keeping one row per old name.
430pub fn alias_table(existing: &str, rows: &[(String, String)]) -> String {
431    let mut seen: BTreeSet<String> = existing.lines().map(str::to_string).collect();
432    let mut out = if existing.is_empty() {
433        "# old\tnew-id  (written by spec import; frozen afterwards)\n".to_string()
434    } else {
435        existing.to_string()
436    };
437    for (old, new) in rows {
438        let line = format!("{old}\t{new}");
439        if seen.insert(line.clone()) {
440            out.push_str(&line);
441            out.push('\n');
442        }
443    }
444    out
445}
446
447/// C243: as C154 keeps an edited file, a source with CRLF anywhere in its body gives a document
448/// that is CRLF throughout (the generated frontmatter included); otherwise LF.
449fn one_ending(content: String) -> String {
450    if content.contains("\r\n") { content.replace("\r\n", "\n").replace('\n', "\r\n") } else { content }
451}
452
453#[cfg(test)]
454mod tests {
455    #[test]
456    fn record_numbers_come_off_titles() {
457        assert_eq!(super::strip_number("2. Use X"), "Use X");
458        assert_eq!(super::strip_number("12. "), "12. ");
459        assert_eq!(super::strip_number("2026 plan"), "2026 plan");
460    }
461
462    #[test]
463    fn sections_come_out_and_links_are_made_relative() {
464        let body = "\n# T\n\n## Status\n\nAccepted\n\n## Context\n\nx\n";
465        assert_eq!(super::without_section(body, "status").unwrap(), "\n# T\n\n## Context\n\nx\n");
466        assert_eq!(super::links::relative(".spec/adr", ".spec/adr/a.adr.md"), "a.adr.md");
467        assert_eq!(super::links::relative("svc/.spec/adr", ".spec/adr/a.md"), "../../../.spec/adr/a.md");
468        assert_eq!(super::links::join("doc/adr", "../x/./y.md").as_deref(), Some("doc/x/y.md"));
469        assert_eq!(super::links::join("", "../y.md"), None);
470        assert_eq!(
471            super::by_candidates("[ADR-0002](0002-use-y.md)"),
472            ["0002-use-y", "0002-use-y.md", "ADR-0002", "0002"]
473        );
474    }
475}