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