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 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 pub slug_note: Option<String>,
45}
46
47fn 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 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 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
185pub 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 _ => (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 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
274fn 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}