1use 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 pub owner: Option<String>,
23 pub slug: Option<String>,
25 pub from: Option<String>,
27 pub local: bool,
28 pub bundle: bool,
30 pub key: Option<String>,
32 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}