use super::plan::Plan;
use super::set;
use crate::corpus::Corpus;
use crate::graph::{Graph, Resolution};
use crate::markdown::frontmatter::{edit, render};
use crate::model::doctype::KeyKind;
use crate::model::{DocId, Tier, stamp};
use crate::placement::{self, paths};
use anyhow::{Result, anyhow, bail};
use serde::Serialize;
use serde_json::{Map, Value, json};
#[derive(Debug, Clone, Default)]
pub struct NewDoc {
pub ty: String,
pub title: String,
pub governs: Vec<String>,
pub owner: Option<String>,
pub slug: Option<String>,
pub from: Option<String>,
pub local: bool,
pub bundle: bool,
pub key: Option<String>,
pub body: Option<String>,
pub depends_on: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Created {
pub id: String,
pub path: String,
pub owner_reason: String,
pub slug_note: Option<String>,
}
fn derive_slug(title: &str, governs: &[String]) -> Result<(String, Option<String>)> {
let from_title = stamp::slugify(title);
let letters = title.chars().filter(|c| c.is_alphanumeric()).count();
let kept = title.chars().filter(|c| c.is_ascii_alphanumeric()).count();
if letters == 0 || kept * 2 >= letters {
if !stamp::is_slug(&from_title) {
bail!("cannot derive a slug from the title; pass --slug <kebab-case>");
}
let dropped: Vec<&str> =
title.split_whitespace().filter(|w| w.chars().any(|c| c.is_alphanumeric() && !c.is_ascii())).collect();
let note = (!dropped.is_empty())
.then(|| format!("the slug '{from_title}' leaves out '{}'; pass --slug to name it", dropped.join(" ")));
return Ok((from_title, note));
}
let from_path = governs
.iter()
.filter_map(|g| g.trim_end_matches('/').rsplit('/').next())
.map(|seg| stamp::slugify(seg.split('.').next().unwrap_or(seg)))
.find(|s| stamp::is_slug(s));
match (from_path, stamp::is_slug(&from_title)) {
(Some(p), _) => {
let note = format!(
"the title is mostly non-ASCII, so the slug '{p}' comes from the governed path; pass --slug to name it"
);
Ok((p, Some(note)))
}
(None, true) => {
let note =
format!("the title is mostly non-ASCII, so the slug keeps only '{from_title}'; pass --slug to name it");
Ok((from_title, Some(note)))
}
(None, false) => bail!(
"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"
),
}
}
pub fn plan(c: &Corpus, g: &Graph, req: &NewDoc) -> Result<(Created, Plan)> {
let ty = c
.cfg
.registry
.get(&req.ty)
.ok_or_else(|| anyhow!("unknown type '{}' (have: {})", req.ty, c.cfg.registry.names().join(", ")))?;
if req.title.trim().is_empty() {
bail!("--title is required");
}
let mut owned = req.clone();
for g in &mut owned.governs {
if g.contains(['[', '*', '?', '{']) && c.cfg.root.join(g.as_str()).exists() {
*g = crate::workspace::escape_glob(g);
}
}
let req = &owned;
let root_owner = c.owners.root();
let decision = placement::decide(&c.owners, &req.governs, root_owner);
let (owner, reason) = match &req.owner {
Some(o) => {
let ow = c.owners.by_id(o).or_else(|| c.owners.by_path(o)).ok_or_else(|| anyhow!("no owner '{o}'"))?;
if !req.local && !req.governs.is_empty() && ow.id != decision.owner.id {
bail!(
"--owner {} contradicts governs: the document belongs to {} ({})",
ow.id,
decision.owner.id,
decision.reason
);
}
(ow, format!("owner given as {}", ow.id))
}
None => (decision.owner, decision.reason.clone()),
};
let (slug, slug_note) = match &req.slug {
Some(s) => (s.clone(), None),
None => derive_slug(&req.title, &req.governs)?,
};
if !stamp::is_slug(&slug) {
bail!("'{slug}' is not a kebab-case slug; pass --slug in lowercase words joined by '-'");
}
let mut key = match ty.key {
KeyKind::Slug => slug.clone(),
KeyKind::Stamp => req.key.clone().unwrap_or_else(stamp::now_stamp),
};
while g.by_id.contains_key(&DocId::new(&ty.name, &owner.id, &key).to_string()) {
match ty.key {
KeyKind::Slug => bail!("{}:{}/{key} already exists; choose another --slug", ty.name, owner.id),
KeyKind::Stamp => key = stamp::next_stamp(&key).ok_or_else(|| anyhow!("bad stamp {key}"))?,
}
}
let id = DocId::new(&ty.name, &owner.id, &key);
let tier = if req.local { Tier::Local } else { Tier::Committed };
let dir = paths::type_dir(&c.cfg, &c.owners, owner, tier, ty);
let path = paths::main_file(&dir, ty, &paths::stem(ty, &key, &slug), req.bundle);
let hint = if req.local { "change [forbid] paths or the type's directory" } else { "rerun with --local" };
super::forbid::refuse(c, &path, hint)?;
let today = stamp::now_value();
let mut fm = Map::new();
fm.insert("id".into(), Value::String(id.to_string()));
fm.insert("title".into(), Value::String(req.title.trim().to_string()));
fm.insert("status".into(), Value::String(ty.initial.clone()));
fm.insert("created".into(), Value::String(today.clone()));
fm.insert("updated".into(), Value::String(today));
let mut rel = Map::new();
if !req.governs.is_empty() {
rel.insert("governs".into(), json!(req.governs));
}
if !req.depends_on.is_empty() {
let mut ids = vec![];
for r in &req.depends_on {
let Resolution::Doc { idx, .. } = g.resolve(r) else { bail!("--depends-on {r} names no single document") };
ids.push(c.docs[idx].id.to_string());
}
rel.insert("depends_on".into(), json!(ids));
}
let mut plan = Plan::default();
if let Some(from) = &req.from {
let Resolution::Doc { idx, .. } = g.resolve(from) else { bail!("--from {from} names no single document") };
let old = &c.docs[idx];
if old.id.ty != ty.name {
bail!("--from {} is a {}, not a {}", old.id, old.id.ty, ty.name);
}
rel.insert("supersedes".into(), json!([old.id.to_string()]));
let old_plan = set::plan_superseded_by_new(c, idx)?;
plan.changes.extend(old_plan.changes);
}
if !rel.is_empty() {
fm.insert("rel".into(), Value::Object(rel));
}
let template = with_required_sections(&ty.template, &ty.sections);
let body = format!("\n# {}\n\n{}", req.title.trim(), req.body.as_deref().unwrap_or(&template));
plan.write(&path, format!("{}{body}", render(&edit::ordered(fm))), true);
Ok((Created { id: id.to_string(), path, owner_reason: reason, slug_note }, plan))
}
pub fn translation(c: &Corpus, g: &Graph, from: &str, lang: &str, title: Option<&str>) -> Result<(Created, Plan)> {
use crate::config::types::TranslationLayout as L;
if let Some((name, rest)) = crate::remotes::split(&c.cfg, from) {
bail!(
"--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"
);
}
let Resolution::Doc { idx, .. } = g.resolve(from) else { bail!("--from {from} names no single document") };
if let Some(orig) = crate::translation::source_ref(c, idx) {
bail!(
"--from {} is itself a translation of {orig}; translate the original instead: spec new --translation {lang} --from {orig} --write",
c.docs[idx].id
);
}
if crate::translation::lang_problem(lang).is_some() {
bail!("--translation '{lang}' is not a language tag like ko or pt-br");
}
let src = &c.docs[idx];
let ty = c.cfg.registry.get(&src.id.ty).ok_or_else(|| anyhow!("unknown type '{}'", src.id.ty))?;
let t = &c.cfg.raw.translations;
if !t.languages.is_empty() && !t.languages.iter().any(|l| l == lang) {
bail!("'{lang}' is not in [translations] languages ({}); add it there first", t.languages.join(", "));
}
let src_owner = c.owners.by_id(&src.id.owner).ok_or_else(|| anyhow!("no owner '{}'", src.id.owner))?;
let (owner, slug) = match t.layout {
L::Dir => {
let want = if src_owner.path.is_empty() { lang.to_string() } else { format!("{}/{lang}", src_owner.path) };
let o = c.owners.by_path(&want).ok_or_else(|| {
anyhow!(
"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"
)
})?;
(o, src.slug.clone())
}
_ => (src_owner, format!("{}-{}", src.slug, lang.to_ascii_lowercase())),
};
let mut key = match ty.key {
KeyKind::Slug => slug.clone(),
KeyKind::Stamp => stamp::now_stamp(),
};
while g.by_id.contains_key(&DocId::new(&ty.name, &owner.id, &key).to_string()) {
match ty.key {
KeyKind::Slug => bail!("{}:{}/{key} already exists: the '{lang}' translation is there", ty.name, owner.id),
KeyKind::Stamp => key = stamp::next_stamp(&key).ok_or_else(|| anyhow!("bad stamp {key}"))?,
}
}
let id = DocId::new(&ty.name, &owner.id, &key);
let bundle = src.dir.is_some();
let dir = paths::type_dir(&c.cfg, &c.owners, owner, src.tier, ty);
let stem = paths::stem(ty, &key, &slug);
let path = paths::main_file(&dir, ty, &stem, bundle);
super::forbid::refuse(c, &path, "change [forbid] paths or translate in another place")?;
let read = |p: &str| std::fs::read_to_string(c.cfg.root.join(p)).map_err(|e| anyhow!("cannot read {p}: {e}"));
let rev = crate::translation::rev_of(c, idx).ok_or_else(|| anyhow!("cannot read {}", src.path))?;
let today = stamp::now_value();
let mut fm = Map::new();
fm.insert("id".into(), Value::String(id.to_string()));
let title = title.map(str::to_string).or_else(|| src.title.clone()).unwrap_or_else(|| src.slug.replace('-', " "));
fm.insert("title".into(), Value::String(title.trim().to_string()));
let status = if ty.statuses.iter().any(|s| s == "draft") { "draft".to_string() } else { ty.initial.clone() };
fm.insert("status".into(), Value::String(status));
fm.insert("created".into(), Value::String(today.clone()));
fm.insert("updated".into(), Value::String(today));
fm.insert("lang".into(), Value::String(lang.to_string()));
fm.insert(crate::translation::KEY.into(), Value::String(rev));
fm.insert("rel".into(), json!({ crate::translation::RELATION: [src.id.to_string()] }));
let mut plan = Plan::default();
let body = crate::markdown::frontmatter::split(&read(&src.path)?).body.to_string();
plan.write(&path, format!("{}{body}", render(&edit::ordered(fm))), true);
if let Some(src_dir) = &src.dir {
let new_dir = path.rsplit_once('/').map(|x| x.0).unwrap_or("").to_string();
for ch in &src.chapters {
let rest = ch.strip_prefix(&format!("{src_dir}/")).unwrap_or(ch);
plan.write(format!("{new_dir}/{rest}"), read(ch)?, true);
}
for f in &src.bundle_files {
let rest = f.strip_prefix(&format!("{src_dir}/")).unwrap_or(f);
plan.copy(f.clone(), format!("{new_dir}/{rest}"));
}
}
let reason = format!("the '{lang}' translation of {}", src.id);
Ok((Created { id: id.to_string(), path, owner_reason: reason, slug_note: None }, plan))
}
fn with_required_sections(template: &str, required: &[String]) -> String {
let have: Vec<String> = template
.lines()
.filter(|l| l.starts_with('#'))
.map(|l| l.trim_start_matches('#').trim().to_lowercase())
.collect();
let mut out = template.to_string();
for s in required {
let s = s.trim();
if s.is_empty() || have.contains(&s.to_lowercase()) {
continue;
}
if !out.is_empty() && !out.ends_with("\n\n") {
out.push_str(if out.ends_with('\n') { "\n" } else { "\n\n" });
}
out.push_str(&format!("## {s}\n"));
}
out
}
#[cfg(test)]
mod required_sections_tests {
#[test]
fn missing_required_sections_are_appended_once() {
let req = vec!["Decision".to_string(), "Risks".to_string()];
assert_eq!(
super::with_required_sections("## Context\n\n## Decision\n", &req),
"## Context\n\n## Decision\n\n## Risks\n"
);
assert_eq!(super::with_required_sections("", &req), "## Decision\n\n## Risks\n");
}
}