Skip to main content

specfold_core/authoring/
mv.rs

1//! Moving or renaming a document, and rewriting every reference to it.
2
3use super::plan::Plan;
4use super::refs;
5use crate::corpus::Corpus;
6use crate::markdown::frontmatter::edit;
7use crate::model::doctype::KeyKind;
8use crate::model::{DocId, Tier, stamp};
9use crate::placement::{self, paths};
10use anyhow::{Result, anyhow, bail};
11use serde::Serialize;
12use serde_json::Value;
13
14#[derive(Debug, Clone, Default)]
15pub struct Move {
16    /// Target owner; computed from governs when absent.
17    pub owner: Option<String>,
18    /// New slug.
19    pub slug: Option<String>,
20    /// Target tier: promote a local document into the committed tree, or the reverse.
21    pub tier: Option<Tier>,
22}
23
24#[derive(Debug, Clone, Serialize)]
25pub struct Moved {
26    pub from: String,
27    pub to: String,
28    pub path: String,
29    pub references: usize,
30}
31
32pub fn plan(c: &Corpus, idx: usize, req: &Move) -> Result<(Moved, Plan)> {
33    let d = &c.docs[idx];
34    let ty = c.cfg.registry.get(&d.id.ty).ok_or_else(|| anyhow!("unknown type {}", d.id.ty))?;
35    let current = c.owners.by_id(&d.id.owner).ok_or_else(|| anyhow!("owner {} no longer exists", d.id.owner))?;
36    let owner = match &req.owner {
37        Some(o) => c.owners.by_id(o).or_else(|| c.owners.by_path(o)).ok_or_else(|| anyhow!("no owner '{o}'"))?,
38        None => {
39            placement::decide_local(&c.cfg, &c.owners, &d.rel().remove("governs").unwrap_or_default(), current).owner
40        }
41    };
42    let slug = req.slug.clone().unwrap_or_else(|| d.slug.clone());
43    if !stamp::is_slug(&slug) {
44        bail!("'{slug}' is not a kebab-case slug");
45    }
46    let key = if ty.key == KeyKind::Slug { slug.clone() } else { d.id.key.clone() };
47    let new_id = DocId::new(&ty.name, &owner.id, &key);
48    let tier = req.tier.unwrap_or(d.tier);
49    let dir = paths::type_dir(&c.cfg, &c.owners, owner, tier, ty);
50    let stem = paths::stem(ty, &key, &slug);
51    let new_main = paths::main_file(&dir, ty, &stem, d.dir.is_some());
52    if new_main == d.path {
53        return Ok((
54            Moved { from: d.id.to_string(), to: new_id.to_string(), path: new_main, references: 0 },
55            Plan::default(),
56        ));
57    }
58    let hint = if tier == Tier::Local {
59        "change [forbid] paths or the type's directory"
60    } else {
61        "choose another --owner, or keep it local: spec new --local --write, then move the body"
62    };
63    super::forbid::refuse(c, &new_main, hint)?;
64    if c.docs.iter().enumerate().any(|(j, x)| j != idx && x.id == new_id) {
65        bail!("{new_id} already exists");
66    }
67    let mut plan = Plan::default();
68    match &d.dir {
69        Some(bundle) => plan.mv(bundle.clone(), new_main.rsplit_once('/').map(|x| x.0.to_string()).unwrap_or_default()),
70        None => plan.mv(d.path.clone(), new_main.clone()),
71    }
72    let (old_s, new_s) = (d.id.to_string(), new_id.to_string());
73    let mut references = 0;
74    for (j, other) in c.docs.iter().enumerate() {
75        if other.tier == Tier::Committed && tier == Tier::Local && j != idx {
76            continue;
77        }
78        for file in std::iter::once(&other.path).chain(&other.chapters) {
79            let text = std::fs::read_to_string(c.cfg.root.join(file))?;
80            let mut body = refs::rewrite_body(&text, &old_s, &new_s);
81            if j != idx {
82                // C137: relative Markdown links to the moved file follow it, fragment kept.
83                body = refs::rewrite_relative_links(&body, file, &d.path, &new_main);
84                if let Some(bundle) = &d.dir {
85                    let new_dir = new_main.rsplit_once('/').map(|x| x.0).unwrap_or("");
86                    body = refs::rewrite_bundle_links(&body, file, bundle, new_dir);
87                }
88                // Plain paths to the moved file break with the move; cite the new id instead (L2).
89                for cite in other.path_cites.iter().filter(|p| &p.path == file) {
90                    if refs::same_path(&cite.item, &d.path) {
91                        body = refs::cite_path_as_id(&body, &cite.item, &new_s);
92                    }
93                }
94            }
95            let is_main = file == &other.path;
96            if is_main {
97                // E3: only the fields that change are written; quoting elsewhere stays.
98                body = edit::rewrite_keeping(&body, &[(old_s.as_str(), new_s.as_str())], |m| {
99                    refs::rewrite_rel(m, &old_s, &new_s);
100                    if j == idx {
101                        m.insert("id".into(), Value::String(new_s.clone()));
102                        m.insert("updated".into(), Value::String(stamp::now_value()));
103                    }
104                })?;
105            }
106            // C224: a referring file counts once, whether its links, its rel lines, or both changed.
107            if body != text {
108                references += usize::from(j != idx);
109                let target = if j != idx {
110                    file.clone()
111                } else if is_main {
112                    new_main.clone()
113                } else {
114                    let base = d.dir.as_deref().unwrap_or("");
115                    let new_base = new_main.rsplit_once('/').map(|x| x.0).unwrap_or("");
116                    file.replacen(base, new_base, 1)
117                };
118                plan.write(target, body, false);
119            }
120        }
121    }
122    // D2: baseline entries follow the moved file or bundle, so its known errors stay held.
123    let (from_path, to_path) = match &d.dir {
124        Some(bundle) => (bundle.clone(), new_main.rsplit_once('/').map(|x| x.0.to_string()).unwrap_or_default()),
125        None => (d.path.clone(), new_main.clone()),
126    };
127    if let Some((rel, text)) = crate::check::baseline::follow_move(c, &from_path, &to_path)? {
128        plan.write(rel, text, false);
129    }
130    if let Some(file) = &c.aliases.file
131        && let Ok(text) = std::fs::read_to_string(c.cfg.root.join(file))
132    {
133        let new_text: String = text
134            .lines()
135            .map(|l| match l.split_once('\t') {
136                Some((o, n)) if n.trim() == old_s => format!("{o}\t{new_s}\n"),
137                _ => format!("{l}\n"),
138            })
139            .collect();
140        if new_text != text {
141            plan.write(file.clone(), new_text, false);
142        }
143    }
144    Ok((Moved { from: old_s, to: new_s, path: new_main, references }, plan))
145}