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
21#[derive(Debug, Clone, Default, Serialize)]
22pub struct Row {
23    pub source: String,
24    /// `doc` or `redirect`.
25    pub kind: String,
26    pub id: String,
27    pub path: String,
28    pub owner_reason: String,
29    pub status_from: String,
30    pub status_to: String,
31    pub aliases: Vec<String>,
32    pub unresolved: Vec<String>,
33}
34
35#[derive(Debug, Default, Serialize)]
36pub struct ImportPlan {
37    pub rows: Vec<Row>,
38    #[serde(skip)]
39    pub plan: Plan,
40    pub alias_rows: Vec<(String, String)>,
41    /// Attachments over the repository limit: (bundle path, sha256) to upload to the store.
42    pub uploads: Vec<(String, String)>,
43}
44
45fn name_of(p: &str) -> &str {
46    p.rsplit('/').next().unwrap_or(p)
47}
48
49/// A file's name without `.md`, or a directory's name.
50fn stem_of(p: &str) -> &str {
51    name_of(p).trim_end_matches(".md")
52}
53
54pub fn build(c: &Corpus, map: &ImportMap) -> Result<ImportPlan> {
55    let units = sources::expand(&c.cfg.root, map);
56    let mut out = ImportPlan::default();
57    let mut taken: BTreeSet<String> = c.docs.iter().map(|d| d.id.to_string()).collect();
58    let mut by_source: BTreeMap<String, String> = BTreeMap::new();
59    let mut redirects = vec![];
60    for u in &units {
61        let rule = &map.sources[u.rule];
62        if let Some(re) = &rule.redirect {
63            redirects.push((u.clone(), Regex::new(re).map_err(|e| anyhow!("bad redirect regex: {e}"))?));
64            continue;
65        }
66        if rule.drop {
67            out.plan.remove(u.source.clone());
68            out.rows.push(Row { source: u.source.clone(), kind: "drop".into(), ..Row::default() });
69            continue;
70        }
71        let row = doc_row(c, map, u, &mut taken, &mut out)?;
72        for p in [&u.main, &u.source].into_iter().chain(&u.chapters).chain(&u.attachments) {
73            by_source.insert(p.clone(), row.id.clone());
74        }
75        if map.stem_aliases {
76            out.alias_rows.push((stem_of(&u.source).to_string(), row.id.clone()));
77        }
78        out.rows.push(row);
79    }
80    for (u, re) in redirects {
81        let text = std::fs::read_to_string(c.cfg.root.join(&u.main)).unwrap_or_default();
82        let target = re
83            .captures(&text)
84            .and_then(|cap| cap.name("target"))
85            .map(|m| m.as_str().trim().trim_start_matches("./").to_string());
86        let resolved = target.as_ref().and_then(|t| {
87            by_source.get(t).cloned().or_else(|| c.docs.iter().find(|d| &d.path == t).map(|d| d.id.to_string()))
88        });
89        let mut unresolved = vec![];
90        match &resolved {
91            Some(id) => {
92                out.alias_rows.push((u.source.clone(), id.clone()));
93                if map.stem_aliases {
94                    out.alias_rows.push((stem_of(&u.source).to_string(), id.clone()));
95                }
96            }
97            None => unresolved.push(format!("stub target {} is not imported", target.unwrap_or_else(|| "?".into()))),
98        }
99        out.plan.remove(u.source.clone());
100        out.rows.push(Row {
101            source: u.source.clone(),
102            kind: "redirect".into(),
103            id: resolved.unwrap_or_default(),
104            path: String::new(),
105            owner_reason: String::new(),
106            status_from: String::new(),
107            status_to: String::new(),
108            aliases: vec![],
109            unresolved,
110        });
111    }
112    Ok(out)
113}
114
115fn doc_row(c: &Corpus, map: &ImportMap, u: &Unit, taken: &mut BTreeSet<String>, out: &mut ImportPlan) -> Result<Row> {
116    let rule = &map.sources[u.rule];
117    let ty_name = rule.ty.clone().ok_or_else(|| anyhow!("source {} needs a type", rule.glob))?;
118    let ty = c.cfg.registry.get(&ty_name).ok_or_else(|| anyhow!("unknown type {ty_name}"))?;
119    // A wrapped unit has no Markdown of its own; its document is generated around the files.
120    let generated = u.main.is_empty();
121    let text = if generated { String::new() } else { std::fs::read_to_string(c.cfg.root.join(&u.main))? };
122    let split = frontmatter::split(&text);
123    let mut unresolved = vec![];
124    let old = match frontmatter::parse(split.raw) {
125        frontmatter::Parsed::Map(m) => m,
126        frontmatter::Parsed::None => Map::new(),
127        frontmatter::Parsed::Invalid(e) => {
128            unresolved.push(format!("old frontmatter does not parse: {e}"));
129            Map::new()
130        }
131    };
132    let scanned = body::scan(split.body, split.body_line, &body::path_cite_re(c.cfg.spec_dir()));
133    let name = name_of(&u.source).trim_end_matches(".md");
134    let name_re = rule.name.as_deref().map(Regex::new).transpose().map_err(|e| anyhow!("bad name regex: {e}"))?;
135    let caps = name_re.as_ref().and_then(|r| r.captures(name_of(&u.source)));
136    let named = caps.as_ref().and_then(|c| c.name("slug")).map(|m| m.as_str()).unwrap_or(name);
137    let fallback_title = scanned.first_heading.clone().unwrap_or_else(|| named.replace(['-', '_'], " "));
138    let mut conv = convert::frontmatter(map, rule, &old, &ty.statuses, &fallback_title);
139    unresolved.append(&mut conv.unresolved);
140    let title = conv.fm.get("title").and_then(Value::as_str).unwrap_or(&fallback_title).to_string();
141    let created = conv.fm.get("created").and_then(Value::as_str).map(str::to_string);
142    let src =
143        key::Source { path: &u.main, caps: caps.as_ref(), created: created.as_deref(), title: &title, name_stem: name };
144    let (mut k, slug) = key::choose(&c.cfg.root, rule, ty, &src);
145    let governs: Vec<String> = conv
146        .fm
147        .get("rel")
148        .and_then(|r| r.get("governs"))
149        .map(|v| crate::model::doc::list_of(Some(v)))
150        .unwrap_or_default();
151    let (owner, owner_reason) = match rule.owner.as_str() {
152        "auto" => {
153            let home = c.owners.storage_owner(c.owners.containing(&u.source));
154            owner::choose(&c.owners, &governs, &text, home)
155        }
156        fixed => {
157            let o = c.owners.by_id(fixed).ok_or_else(|| anyhow!("source {}: no owner '{fixed}'", rule.glob))?;
158            (o, format!("fixed to {fixed} by the import map"))
159        }
160    };
161    loop {
162        let id = DocId::new(&ty.name, &owner.id, &k).to_string();
163        if !taken.contains(&id) {
164            break;
165        }
166        k = match ty.key {
167            KeyKind::Stamp => stamp::next_stamp(&k).unwrap_or(k),
168            KeyKind::Slug => format!("{k}-2"),
169        };
170    }
171    let slug = if ty.key == KeyKind::Slug { k.clone() } else { slug };
172    let id = DocId::new(&ty.name, &owner.id, &k);
173    taken.insert(id.to_string());
174    let bundle = !u.chapters.is_empty() || !u.attachments.is_empty() || ty.form == Form::Dir;
175    let dir = paths::type_dir(&c.cfg, &c.owners, owner, Tier::Committed, ty);
176    let main = paths::main_file(&dir, ty, &paths::stem(ty, &k, &slug), bundle);
177    let bundle_dir = main.rsplit_once('/').map(|x| x.0.to_string()).unwrap_or_default();
178    let base = if u.source == u.main { u.main.trim_end_matches(".md").to_string() } else { u.source.clone() };
179    let mut files = vec![];
180    // Chapters and attachments move: a sibling directory is not the unit's source, so removing
181    // the source alone would leave them behind.
182    for ch in &u.chapters {
183        let rel = ch.strip_prefix(&format!("{base}/")).unwrap_or(name_of(ch));
184        out.plan.copy(ch.clone(), format!("{bundle_dir}/{}", rel.replace('/', "-")));
185        out.plan.remove(ch.clone());
186    }
187    for a in &u.attachments {
188        let rel = a.strip_prefix(&format!("{base}/")).unwrap_or(name_of(a)).to_string();
189        let (sha, bytes) = hash::file(&c.cfg.root.join(a))?;
190        let store = if bytes > c.cfg.raw.files.inline_max_bytes { "store" } else { "repo" };
191        if store == "store" {
192            out.uploads.push((format!("{bundle_dir}/{rel}"), sha.clone()));
193        }
194        out.plan.copy(a.clone(), format!("{bundle_dir}/{rel}"));
195        out.plan.remove(a.clone());
196        files.push(json!({"path": rel, "sha256": sha, "bytes": bytes, "store": store}));
197    }
198    let mut fm = conv.fm;
199    fm.insert("id".into(), Value::String(id.to_string()));
200    fm.entry("created").or_insert_with(|| Value::String(stamp::normalize_date(&k).unwrap_or_else(stamp::today)));
201    let created_v = fm["created"].clone();
202    let updated = fm.entry("updated").or_insert(created_v.clone());
203    // Old records sometimes date an edit before the file existed; the later date wins.
204    if updated.as_str() < created_v.as_str() {
205        *updated = created_v;
206    }
207    let mut aliases = conv.aliases;
208    if let Some(a) = caps.as_ref().and_then(|c| c.name("alias")) {
209        aliases.push(a.as_str().to_string());
210    }
211    if !aliases.is_empty() {
212        fm.insert("aliases".into(), json!(aliases));
213    }
214    if !files.is_empty() {
215        fm.insert("files".into(), Value::Array(files));
216    }
217    let body = if generated { format!("\n# {title}\n") } else { split.body.to_string() };
218    let content = format!("{}{}", frontmatter::render(&crate::markdown::frontmatter::edit::ordered(fm)), body);
219    out.plan.write(main.clone(), content, true);
220    out.plan.remove(u.source.clone());
221    // Old paths of chapters and attachments name the document too, so their citations resolve.
222    for old in std::iter::once(u.source.clone())
223        .chain((u.main != u.source).then(|| u.main.clone()))
224        .chain(u.chapters.iter().cloned())
225        .chain(u.attachments.iter().cloned())
226        .chain(aliases.iter().cloned())
227    {
228        out.alias_rows.push((old, id.to_string()));
229    }
230    Ok(Row {
231        source: u.source.clone(),
232        kind: "doc".into(),
233        id: id.to_string(),
234        path: main,
235        owner_reason,
236        status_from: conv.status_from,
237        status_to: fm_status(&out.plan, &id.to_string()),
238        aliases,
239        unresolved,
240    })
241}
242
243fn fm_status(plan: &Plan, _id: &str) -> String {
244    plan.changes
245        .iter()
246        .rev()
247        .find_map(|c| match c {
248            crate::authoring::Change::Write { content, .. } => {
249                content.lines().find_map(|l| l.strip_prefix("status: ").map(str::to_string))
250            }
251            _ => None,
252        })
253        .unwrap_or_default()
254}
255
256/// Appends the alias rows to the table file (creating it), keeping one row per old name.
257pub fn alias_table(existing: &str, rows: &[(String, String)]) -> String {
258    let mut seen: BTreeSet<String> = existing.lines().map(str::to_string).collect();
259    let mut out = if existing.is_empty() {
260        "# old\tnew-id  (written by spec import; frozen afterwards)\n".to_string()
261    } else {
262        existing.to_string()
263    };
264    for (old, new) in rows {
265        let line = format!("{old}\t{new}");
266        if seen.insert(line.clone()) {
267            out.push_str(&line);
268            out.push('\n');
269        }
270    }
271    out
272}