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}
42
43pub fn plan(c: &Corpus, g: &Graph, req: &NewDoc) -> Result<(Created, Plan)> {
44    let ty = c
45        .cfg
46        .registry
47        .get(&req.ty)
48        .ok_or_else(|| anyhow!("unknown type '{}' (have: {})", req.ty, c.cfg.registry.names().join(", ")))?;
49    if req.title.trim().is_empty() {
50        bail!("--title is required");
51    }
52    let root_owner = c.owners.root();
53    let decision = placement::decide(&c.owners, &req.governs, root_owner);
54    let (owner, reason) = match &req.owner {
55        Some(o) => {
56            let ow = c.owners.by_id(o).or_else(|| c.owners.by_path(o)).ok_or_else(|| anyhow!("no owner '{o}'"))?;
57            if !req.local && !req.governs.is_empty() && ow.id != decision.owner.id {
58                bail!(
59                    "--owner {} contradicts governs: the document belongs to {} ({})",
60                    ow.id,
61                    decision.owner.id,
62                    decision.reason
63                );
64            }
65            (ow, format!("owner given as {}", ow.id))
66        }
67        None => (decision.owner, decision.reason.clone()),
68    };
69    let slug = match &req.slug {
70        Some(s) => s.clone(),
71        None => stamp::slugify(&req.title),
72    };
73    if !stamp::is_slug(&slug) {
74        bail!("cannot derive a slug from the title; pass --slug <kebab-case>");
75    }
76    let mut key = match ty.key {
77        KeyKind::Slug => slug.clone(),
78        KeyKind::Stamp => req.key.clone().unwrap_or_else(stamp::now_stamp),
79    };
80    while g.by_id.contains_key(&DocId::new(&ty.name, &owner.id, &key).to_string()) {
81        match ty.key {
82            KeyKind::Slug => bail!("{}:{}/{key} already exists; choose another --slug", ty.name, owner.id),
83            KeyKind::Stamp => key = stamp::next_stamp(&key).ok_or_else(|| anyhow!("bad stamp {key}"))?,
84        }
85    }
86    let id = DocId::new(&ty.name, &owner.id, &key);
87    let tier = if req.local { Tier::Local } else { Tier::Committed };
88    let dir = paths::type_dir(&c.cfg, &c.owners, owner, tier, ty);
89    let path = paths::main_file(&dir, ty, &paths::stem(ty, &key, &slug), req.bundle);
90    let today = stamp::today();
91    let mut fm = Map::new();
92    fm.insert("id".into(), Value::String(id.to_string()));
93    fm.insert("title".into(), Value::String(req.title.trim().to_string()));
94    fm.insert("status".into(), Value::String(ty.initial.clone()));
95    fm.insert("created".into(), Value::String(today.clone()));
96    fm.insert("updated".into(), Value::String(today));
97    let mut rel = Map::new();
98    if !req.governs.is_empty() {
99        rel.insert("governs".into(), json!(req.governs));
100    }
101    let mut plan = Plan::default();
102    if let Some(from) = &req.from {
103        let Resolution::Doc { idx, .. } = g.resolve(from) else { bail!("--from {from} names no single document") };
104        let old = &c.docs[idx];
105        if old.id.ty != ty.name {
106            bail!("--from {} is a {}, not a {}", old.id, old.id.ty, ty.name);
107        }
108        rel.insert("supersedes".into(), json!([old.id.to_string()]));
109        let old_plan = set::plan(c, idx, &[set::Assign::parse("status=superseded")?])?;
110        plan.changes.extend(old_plan.changes);
111    }
112    if !rel.is_empty() {
113        fm.insert("rel".into(), Value::Object(rel));
114    }
115    let body = format!("\n# {}\n\n{}", req.title.trim(), req.body.as_deref().unwrap_or(&ty.template));
116    plan.write(&path, format!("{}{body}", render(&edit::ordered(fm))), true);
117    Ok((Created { id: id.to_string(), path, owner_reason: reason }, plan))
118}