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 pub slug_note: Option<String>,
43}
44
45fn 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
161pub 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 _ => (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 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
251fn 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}