Skip to main content

specfold_core/authoring/
new.rs

1//! Creating a document: compute its owner and path, assign its key, write its frontmatter and
2//! body skeleton.
3
4use super::plan::Plan;
5use super::set;
6use crate::corpus::Corpus;
7use crate::graph::{Graph, Resolution};
8use crate::markdown::frontmatter::{edit, render};
9use crate::model::doctype::KeyKind;
10use crate::model::{DocId, Tier, stamp};
11use crate::placement::{self, paths};
12use anyhow::{Result, anyhow, bail};
13use serde::Serialize;
14use serde_json::{Map, Value, json};
15
16#[derive(Debug, Clone, Default)]
17pub struct NewDoc {
18    pub ty: String,
19    pub title: String,
20    pub governs: Vec<String>,
21    /// Overrides the computed owner (refused if it breaks placement, unless local).
22    pub owner: Option<String>,
23    /// For slug types; defaults to the slugified title.
24    pub slug: Option<String>,
25    /// Supersede this document.
26    pub from: Option<String>,
27    pub local: bool,
28    /// Force a bundle directory even for a single-file type.
29    pub bundle: bool,
30    /// Fixed key, for imports; otherwise now.
31    pub key: Option<String>,
32    /// Body to write instead of the type's template (audit records, generated documents).
33    pub body: Option<String>,
34    /// Documents this one depends on (`rel.depends_on`), e.g. what a change note updates.
35    pub depends_on: Vec<String>,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct Created {
40    pub id: String,
41    pub path: String,
42    pub owner_reason: String,
43    /// Why the slug is not what the title reads as, when it drops most of a non-ASCII title.
44    pub slug_note: Option<String>,
45}
46
47/// A slug from the title. Titles in other scripts lose their words to slugify, so when less than
48/// half the title's letters survive, the governed path's last segment names the slug instead,
49/// and the caller is told; with neither, --slug is required.
50fn derive_slug(title: &str, governs: &[String]) -> Result<(String, Option<String>)> {
51    let from_title = stamp::slugify(title);
52    let letters = title.chars().filter(|c| c.is_alphanumeric()).count();
53    let kept = title.chars().filter(|c| c.is_ascii_alphanumeric()).count();
54    if letters == 0 || kept * 2 >= letters {
55        if !stamp::is_slug(&from_title) {
56            bail!("cannot derive a slug from the title; pass --slug <kebab-case>");
57        }
58        // Mostly ASCII, but words in another script still vanish from the slug: say which.
59        let dropped: Vec<&str> =
60            title.split_whitespace().filter(|w| w.chars().any(|c| c.is_alphanumeric() && !c.is_ascii())).collect();
61        let note = (!dropped.is_empty())
62            .then(|| format!("the slug '{from_title}' leaves out '{}'; pass --slug to name it", dropped.join(" ")));
63        return Ok((from_title, note));
64    }
65    let from_path = governs
66        .iter()
67        .filter_map(|g| g.trim_end_matches('/').rsplit('/').next())
68        .map(|seg| stamp::slugify(seg.split('.').next().unwrap_or(seg)))
69        .find(|s| stamp::is_slug(s));
70    match (from_path, stamp::is_slug(&from_title)) {
71        (Some(p), _) => {
72            let note = format!(
73                "the title is mostly non-ASCII, so the slug '{p}' comes from the governed path; pass --slug to name it"
74            );
75            Ok((p, Some(note)))
76        }
77        (None, true) => {
78            let note =
79                format!("the title is mostly non-ASCII, so the slug keeps only '{from_title}'; pass --slug to name it");
80            Ok((from_title, Some(note)))
81        }
82        (None, false) => bail!(
83            "the title has no ASCII words to make a slug from; give an English kebab-case --slug (replace my-slug); rerun with --slug my-slug"
84        ),
85    }
86}
87
88pub fn plan(c: &Corpus, g: &Graph, req: &NewDoc) -> Result<(Created, Plan)> {
89    let ty = c
90        .cfg
91        .registry
92        .get(&req.ty)
93        .ok_or_else(|| anyhow!("unknown type '{}' (have: {})", req.ty, c.cfg.registry.names().join(", ")))?;
94    if req.title.trim().is_empty() {
95        bail!("--title is required");
96    }
97    // C185: a governs value naming an existing path that holds glob characters is that path, so
98    // it is written escaped; read as a glob it would name other files or none.
99    let mut owned = req.clone();
100    for g in &mut owned.governs {
101        if g.contains(['[', '*', '?', '{']) && c.cfg.root.join(g.as_str()).exists() {
102            *g = crate::workspace::escape_glob(g);
103        }
104    }
105    let req = &owned;
106    let root_owner = c.owners.root();
107    let decision = placement::decide(&c.owners, &req.governs, root_owner);
108    let (owner, reason) = match &req.owner {
109        Some(o) => {
110            let ow = c.owners.by_id(o).or_else(|| c.owners.by_path(o)).ok_or_else(|| anyhow!("no owner '{o}'"))?;
111            if !req.local && !req.governs.is_empty() && ow.id != decision.owner.id {
112                bail!(
113                    "--owner {} contradicts governs: the document belongs to {} ({})",
114                    ow.id,
115                    decision.owner.id,
116                    decision.reason
117                );
118            }
119            (ow, format!("owner given as {}", ow.id))
120        }
121        None => (decision.owner, decision.reason.clone()),
122    };
123    let (slug, slug_note) = match &req.slug {
124        Some(s) => (s.clone(), None),
125        None => derive_slug(&req.title, &req.governs)?,
126    };
127    if !stamp::is_slug(&slug) {
128        bail!("'{slug}' is not a kebab-case slug; pass --slug in lowercase words joined by '-'");
129    }
130    let mut key = match ty.key {
131        KeyKind::Slug => slug.clone(),
132        KeyKind::Stamp => req.key.clone().unwrap_or_else(stamp::now_stamp),
133    };
134    while g.by_id.contains_key(&DocId::new(&ty.name, &owner.id, &key).to_string()) {
135        match ty.key {
136            KeyKind::Slug => bail!("{}:{}/{key} already exists; choose another --slug", ty.name, owner.id),
137            KeyKind::Stamp => key = stamp::next_stamp(&key).ok_or_else(|| anyhow!("bad stamp {key}"))?,
138        }
139    }
140    let id = DocId::new(&ty.name, &owner.id, &key);
141    let tier = if req.local { Tier::Local } else { Tier::Committed };
142    let dir = paths::type_dir(&c.cfg, &c.owners, owner, tier, ty);
143    let path = paths::main_file(&dir, ty, &paths::stem(ty, &key, &slug), req.bundle);
144    let hint = if req.local { "change [forbid] paths or the type's directory" } else { "rerun with --local" };
145    super::forbid::refuse(c, &path, hint)?;
146    let today = stamp::now_value();
147    let mut fm = Map::new();
148    fm.insert("id".into(), Value::String(id.to_string()));
149    fm.insert("title".into(), Value::String(req.title.trim().to_string()));
150    fm.insert("status".into(), Value::String(ty.initial.clone()));
151    fm.insert("created".into(), Value::String(today.clone()));
152    fm.insert("updated".into(), Value::String(today));
153    let mut rel = Map::new();
154    if !req.governs.is_empty() {
155        rel.insert("governs".into(), json!(req.governs));
156    }
157    if !req.depends_on.is_empty() {
158        let mut ids = vec![];
159        for r in &req.depends_on {
160            let Resolution::Doc { idx, .. } = g.resolve(r) else { bail!("--depends-on {r} names no single document") };
161            ids.push(c.docs[idx].id.to_string());
162        }
163        rel.insert("depends_on".into(), json!(ids));
164    }
165    let mut plan = Plan::default();
166    if let Some(from) = &req.from {
167        let Resolution::Doc { idx, .. } = g.resolve(from) else { bail!("--from {from} names no single document") };
168        let old = &c.docs[idx];
169        if old.id.ty != ty.name {
170            bail!("--from {} is a {}, not a {}", old.id, old.id.ty, ty.name);
171        }
172        rel.insert("supersedes".into(), json!([old.id.to_string()]));
173        let old_plan = set::plan_superseded_by_new(c, idx)?;
174        plan.changes.extend(old_plan.changes);
175    }
176    if !rel.is_empty() {
177        fm.insert("rel".into(), Value::Object(rel));
178    }
179    let template = with_required_sections(&ty.template, &ty.sections);
180    let body = format!("\n# {}\n\n{}", req.title.trim(), req.body.as_deref().unwrap_or(&template));
181    plan.write(&path, format!("{}{body}", render(&edit::ordered(fm))), true);
182    Ok((Created { id: id.to_string(), path, owner_reason: reason, slug_note }, plan))
183}
184
185/// `spec new --translation <lang> --from <source>`: a draft with its own key where `[translations]`
186/// pairs it (`<slug>-<lang>` beside the source, or under the `<lang>` owner for `layout = "dir"`),
187/// with the source's body and chapters copied and lang, rel.translation_of, source_rev recorded.
188pub fn translation(c: &Corpus, g: &Graph, from: &str, lang: &str, title: Option<&str>) -> Result<(Created, Plan)> {
189    use crate::config::types::TranslationLayout as L;
190    if let Some((name, rest)) = crate::remotes::split(&c.cfg, from) {
191        bail!(
192            "--from {from} is in the remote {name}, which specfold reads but never copies from; copy {rest} into this repository first (or translate it in that repository), then: spec new --translation {lang} --from <the local id> --write. A local translation may still name a remote source in rel.translation_of; R1 tracks it"
193        );
194    }
195    let Resolution::Doc { idx, .. } = g.resolve(from) else { bail!("--from {from} names no single document") };
196    if let Some(orig) = crate::translation::source_ref(c, idx) {
197        bail!(
198            "--from {} is itself a translation of {orig}; translate the original instead: spec new --translation {lang} --from {orig} --write",
199            c.docs[idx].id
200        );
201    }
202    if crate::translation::lang_problem(lang).is_some() {
203        bail!("--translation '{lang}' is not a language tag like ko or pt-br");
204    }
205    let src = &c.docs[idx];
206    let ty = c.cfg.registry.get(&src.id.ty).ok_or_else(|| anyhow!("unknown type '{}'", src.id.ty))?;
207    let t = &c.cfg.raw.translations;
208    if !t.languages.is_empty() && !t.languages.iter().any(|l| l == lang) {
209        bail!("'{lang}' is not in [translations] languages ({}); add it there first", t.languages.join(", "));
210    }
211    let src_owner = c.owners.by_id(&src.id.owner).ok_or_else(|| anyhow!("no owner '{}'", src.id.owner))?;
212    let (owner, slug) = match t.layout {
213        L::Dir => {
214            let want = if src_owner.path.is_empty() { lang.to_string() } else { format!("{}/{lang}", src_owner.path) };
215            let o = c.owners.by_path(&want).ok_or_else(|| {
216                anyhow!(
217                    "layout = \"dir\" puts the '{lang}' translation in {want}/, which is not an owner: create the directory {want}/ and add [[owners.kinds]] pattern = \"{want}\" to spec.toml"
218                )
219            })?;
220            (o, src.slug.clone())
221        }
222        // The tag keeps its BCP 47 case in `lang`; in a slug it is lower case (`setup-pt-br`).
223        _ => (src_owner, format!("{}-{}", src.slug, lang.to_ascii_lowercase())),
224    };
225    let mut key = match ty.key {
226        KeyKind::Slug => slug.clone(),
227        KeyKind::Stamp => stamp::now_stamp(),
228    };
229    while g.by_id.contains_key(&DocId::new(&ty.name, &owner.id, &key).to_string()) {
230        match ty.key {
231            KeyKind::Slug => bail!("{}:{}/{key} already exists: the '{lang}' translation is there", ty.name, owner.id),
232            KeyKind::Stamp => key = stamp::next_stamp(&key).ok_or_else(|| anyhow!("bad stamp {key}"))?,
233        }
234    }
235    let id = DocId::new(&ty.name, &owner.id, &key);
236    let bundle = src.dir.is_some();
237    let dir = paths::type_dir(&c.cfg, &c.owners, owner, src.tier, ty);
238    let stem = paths::stem(ty, &key, &slug);
239    let path = paths::main_file(&dir, ty, &stem, bundle);
240    super::forbid::refuse(c, &path, "change [forbid] paths or translate in another place")?;
241    let read = |p: &str| std::fs::read_to_string(c.cfg.root.join(p)).map_err(|e| anyhow!("cannot read {p}: {e}"));
242    let rev = crate::translation::rev_of(c, idx).ok_or_else(|| anyhow!("cannot read {}", src.path))?;
243    let today = stamp::now_value();
244    let mut fm = Map::new();
245    fm.insert("id".into(), Value::String(id.to_string()));
246    let title = title.map(str::to_string).or_else(|| src.title.clone()).unwrap_or_else(|| src.slug.replace('-', " "));
247    fm.insert("title".into(), Value::String(title.trim().to_string()));
248    // A translation starts as a draft where the type has one, whatever the source's status.
249    let status = if ty.statuses.iter().any(|s| s == "draft") { "draft".to_string() } else { ty.initial.clone() };
250    fm.insert("status".into(), Value::String(status));
251    fm.insert("created".into(), Value::String(today.clone()));
252    fm.insert("updated".into(), Value::String(today));
253    fm.insert("lang".into(), Value::String(lang.to_string()));
254    fm.insert(crate::translation::KEY.into(), Value::String(rev));
255    fm.insert("rel".into(), json!({ crate::translation::RELATION: [src.id.to_string()] }));
256    let mut plan = Plan::default();
257    let body = crate::markdown::frontmatter::split(&read(&src.path)?).body.to_string();
258    plan.write(&path, format!("{}{body}", render(&edit::ordered(fm))), true);
259    if let Some(src_dir) = &src.dir {
260        let new_dir = path.rsplit_once('/').map(|x| x.0).unwrap_or("").to_string();
261        for ch in &src.chapters {
262            let rest = ch.strip_prefix(&format!("{src_dir}/")).unwrap_or(ch);
263            plan.write(format!("{new_dir}/{rest}"), read(ch)?, true);
264        }
265        for f in &src.bundle_files {
266            let rest = f.strip_prefix(&format!("{src_dir}/")).unwrap_or(f);
267            plan.copy(f.clone(), format!("{new_dir}/{rest}"));
268        }
269    }
270    let reason = format!("the '{lang}' translation of {}", src.id);
271    Ok((Created { id: id.to_string(), path, owner_reason: reason, slug_note: None }, plan))
272}
273
274/// The type's template plus a heading for each required section it lacks, so a new document
275/// passes S7 from the start.
276fn with_required_sections(template: &str, required: &[String]) -> String {
277    let have: Vec<String> = template
278        .lines()
279        .filter(|l| l.starts_with('#'))
280        .map(|l| l.trim_start_matches('#').trim().to_lowercase())
281        .collect();
282    let mut out = template.to_string();
283    for s in required {
284        let s = s.trim();
285        if s.is_empty() || have.contains(&s.to_lowercase()) {
286            continue;
287        }
288        if !out.is_empty() && !out.ends_with("\n\n") {
289            out.push_str(if out.ends_with('\n') { "\n" } else { "\n\n" });
290        }
291        out.push_str(&format!("## {s}\n"));
292    }
293    out
294}
295
296#[cfg(test)]
297mod required_sections_tests {
298    #[test]
299    fn missing_required_sections_are_appended_once() {
300        let req = vec!["Decision".to_string(), "Risks".to_string()];
301        assert_eq!(
302            super::with_required_sections("## Context\n\n## Decision\n", &req),
303            "## Context\n\n## Decision\n\n## Risks\n"
304        );
305        assert_eq!(super::with_required_sections("", &req), "## Decision\n\n## Risks\n");
306    }
307}