use chrono::Local;
use globset::Glob;
use regex::Regex;
use serde::Serialize;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use crate::config::{Config, FieldType, parse_when};
use crate::error::{Error, Result};
use crate::model::{Graph, Kind};
use crate::parser::identity::infer_id;
#[derive(Debug, Clone)]
pub struct ScaffoldSpec {
pub kind: Kind,
pub title: String,
pub id: Option<String>,
pub path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ScaffoldResult {
pub id: String,
#[serde(serialize_with = "crate::model::node::serialize_path_forward")]
pub path: PathBuf,
pub content: String,
pub written: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
pub fn scaffold(
root: &Path,
spec: ScaffoldSpec,
graph: &Graph,
config: &Config,
write: bool,
force: bool,
) -> Result<ScaffoldResult> {
if !config
.kinds
.allowed
.contains(&spec.kind.as_str().to_string())
{
return Err(Error::Config(format!(
"unknown kind {:?}; allowed: {:?}",
spec.kind.as_str(),
config.kinds.allowed
)));
}
let rel_path = match spec.path.clone() {
Some(p) => p,
None => infer_path(&spec.kind, &spec.title, graph, config)?,
};
if rel_path.extension().and_then(|s| s.to_str()) != Some("md") {
return Err(Error::Config(format!(
"scaffold target must end with .md; got {}",
rel_path.display()
)));
}
crate::path_guard::reject_traversal(&rel_path)?;
let abs_path = root.join(&rel_path);
let id = spec
.id
.clone()
.unwrap_or_else(|| infer_id(&rel_path, &spec.kind, config));
detect_id_collision(&id, &rel_path, root, graph)?;
if abs_path.exists() && !force {
return Err(Error::AlreadyExists { path: abs_path });
}
let content = render_document(&id, &spec, &rel_path, config);
let warnings = collect_scaffold_warnings(&id, &rel_path, &content, config, write);
let written = if write {
write_atomic(&abs_path, &content)?;
true
} else {
false
};
Ok(ScaffoldResult {
id,
path: rel_path,
content,
written,
warnings,
})
}
fn infer_path(kind: &Kind, title: &str, graph: &Graph, config: &Config) -> Result<PathBuf> {
let Some(rule) = config
.identity
.kind_rules
.iter()
.find(|r| r.kind == kind.as_str())
else {
return Err(Error::Config(format!(
"cannot infer path for kind {:?}: no identity.kind_rules match; \
supply `--path` explicitly",
kind.as_str()
)));
};
let dir = directory_from_glob(&rule.glob).ok_or_else(|| {
Error::Config(format!(
"kind_rule glob {:?} does not yield a concrete directory; \
supply `--path` explicitly",
rule.glob
))
})?;
let stem = next_filename_stem(&dir, title, graph, config);
Ok(dir.join(format!("{stem}.md")))
}
fn directory_from_glob(glob: &str) -> Option<PathBuf> {
let mut parts = Vec::new();
for segment in glob.split('/') {
if segment.contains('*') || segment.contains('?') || segment.contains('[') {
break;
}
parts.push(segment);
}
if parts.is_empty() {
return None;
}
Some(parts.iter().collect())
}
fn next_filename_stem(dir: &Path, title: &str, graph: &Graph, config: &Config) -> String {
let slug = slugify(title);
let dir_str = dir.to_string_lossy().replace('\\', "/");
for rule in &config.rules.naming {
if !rule.sequential {
continue;
}
let Ok(glob) = Glob::new(&rule.glob) else {
continue;
};
let matcher = glob.compile_matcher();
if !rule_targets_directory(&rule.glob, &dir_str) {
continue;
}
let (next, width) = next_sequence(graph, &matcher, &rule.pattern);
let padded = format!("{:0>width$}", next, width = width);
return format!("{padded}-{slug}");
}
slug
}
fn rule_targets_directory(glob: &str, dir: &str) -> bool {
let Some(prefix) = directory_from_glob(glob) else {
return false;
};
prefix.to_string_lossy().replace('\\', "/") == dir
}
fn next_sequence(graph: &Graph, matcher: &globset::GlobMatcher, pattern: &str) -> (u64, usize) {
let digit_re = Regex::new(r"^(\d+)").expect("static regex compiles");
let pattern_re = Regex::new(pattern).ok();
let mut max_seen: u64 = 0;
let mut width: usize = 4;
for node in graph.nodes().values() {
let path_str = node.path.to_string_lossy().replace('\\', "/");
if !matcher.is_match(&path_str) {
continue;
}
if let Some(ref re) = pattern_re
&& let Some(stem) = node.path.file_name().and_then(|s| s.to_str())
&& !re.is_match(stem)
{
continue;
}
let Some(stem) = node.path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let Some(cap) = digit_re.captures(stem) else {
continue;
};
let digits = cap.get(1).unwrap().as_str();
width = width.max(digits.len());
if let Ok(n) = digits.parse::<u64>() {
max_seen = max_seen.max(n);
}
}
(max_seen + 1, width)
}
fn slugify(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut last_hyphen = true; for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
last_hyphen = false;
} else if !last_hyphen {
out.push('-');
last_hyphen = true;
}
}
while out.ends_with('-') {
out.pop();
}
out
}
pub fn render_default_frontmatter(id: &str, title: &str, kind: &str, config: &Config) -> String {
let required: Vec<String> = config.required_for(kind).to_vec();
let today = Local::now().date_naive().to_string();
let mut lines: Vec<String> = Vec::new();
let mut emit = |key: &str, value: String| {
lines.push(format!("{key}: {value}"));
};
emit("id", yaml_quote(id));
emit("title", yaml_quote(title));
emit("kind", yaml_quote(kind));
let default_status = config.initial_status_for(kind);
emit("status", yaml_quote(default_status));
let mut seen: std::collections::BTreeSet<String> = ["id", "title", "kind", "status"]
.into_iter()
.map(String::from)
.collect();
for field in &required {
if seen.contains(field) {
continue;
}
let value = default_for_field(field, kind, config, &today);
emit(field, value);
seen.insert(field.clone());
}
let default_node = scaffold_default_node(kind, default_status);
for cf in config.cross_field_for(kind) {
let Ok(predicate) = parse_when(&cf.when) else {
continue;
};
if !crate::rules::schema::predicate_matches_node(&predicate, &default_node) {
continue;
}
if seen.contains(&cf.require) {
continue;
}
let value = default_for_field(&cf.require, kind, config, &today);
emit(&cf.require, value);
seen.insert(cf.require.clone());
}
lines.join("\n")
}
fn render_document(id: &str, spec: &ScaffoldSpec, path: &Path, config: &Config) -> String {
let frontmatter = render_default_frontmatter(id, &spec.title, spec.kind.as_str(), config);
let stem_title = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Document");
let body_heading = if spec.title.is_empty() {
stem_title.to_string()
} else {
spec.title
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect::<String>()
};
format!("---\n{frontmatter}\n---\n\n# {body_heading}\n")
}
fn scaffold_default_node(kind: &str, default_status: &str) -> crate::model::Node {
crate::model::Node {
id: String::new(),
path: PathBuf::new(),
title: String::new(),
kind: crate::model::Kind::new(kind),
status: crate::model::Status::new(default_status),
created: None,
updated: None,
reviewed: None,
owner: None,
supersedes: vec![],
superseded_by: None,
implements: vec![],
related: vec![],
tags: vec![],
orphan_ok: false,
attrs: Default::default(),
}
}
fn yaml_quote(value: &str) -> String {
let mut escaped = String::with_capacity(value.len() + 2);
escaped.push('"');
for c in value.chars() {
match c {
'"' => escaped.push_str("\\\""),
'\\' => escaped.push_str("\\\\"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
c if (c as u32) < 0x20 => {
escaped.push_str(&format!("\\x{:02x}", c as u32));
}
c => escaped.push(c),
}
}
escaped.push('"');
escaped
}
fn default_for_field(field: &str, kind: &str, config: &Config, today: &str) -> String {
let enums = config.enums_for(kind);
if let Some(allowed) = enums.get(field)
&& let Some(first) = allowed.first()
{
return first.clone();
}
let types = config.types_for(kind);
if let Some(ty) = types.get(field) {
return match ty {
FieldType::Date => today.to_string(),
FieldType::Integer => "0".to_string(),
FieldType::Bool => "false".to_string(),
FieldType::String => "\"\"".to_string(),
};
}
match field {
"created" | "updated" | "reviewed" => today.to_string(),
"owner" | "superseded_by" => "\"\"".to_string(),
"supersedes" | "implements" | "related" | "tags" => "[]".to_string(),
_ => "\"\"".to_string(),
}
}
fn detect_id_collision(id: &str, rel_path: &Path, root: &Path, graph: &Graph) -> Result<()> {
if let Some(existing) = graph.nodes().get(id) {
if existing.path != rel_path {
return Err(Error::DuplicateId {
id: id.to_string(),
first: existing.path.clone(),
second: rel_path.to_path_buf(),
});
}
}
if let Some(existing) = scan_disk_for_id(id, rel_path, root) {
return Err(Error::DuplicateId {
id: id.to_string(),
first: existing,
second: rel_path.to_path_buf(),
});
}
Ok(())
}
fn scan_disk_for_id(id: &str, rel_path: &Path, root: &Path) -> Option<PathBuf> {
let parent = rel_path.parent()?;
let dir = root.join(parent);
let target_abs = root.join(rel_path);
let entries = std::fs::read_dir(&dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
if std::fs::canonicalize(&path).ok() == std::fs::canonicalize(&target_abs).ok()
&& target_abs.exists()
{
continue;
}
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let (yaml, _) = crate::parser::frontmatter::split_frontmatter(&content);
let Some(yaml) = yaml else { continue };
for line in yaml.lines() {
if let Some(rest) = line.strip_prefix("id:") {
let value = rest.trim().trim_matches(['"', '\'']);
if value == id {
let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
return Some(rel);
}
break;
}
}
}
None
}
fn collect_scaffold_warnings(
id: &str,
rel_path: &Path,
content: &str,
config: &Config,
written: bool,
) -> Vec<String> {
let mut warnings = Vec::new();
if let Ok((node, _)) = crate::parser::frontmatter::parse_frontmatter(rel_path, content) {
let mut map = indexmap::IndexMap::new();
map.insert(id.to_string(), node);
let graph = Graph::new(map, vec![]);
for v in crate::rules::check_all(&graph, config) {
warnings.push(format!("{}: {}", v.rule_id, v.message));
}
}
if written {
warnings.push("run `nodex build` to include this document in the graph".to_string());
}
warnings
}
fn write_atomic(target: &Path, content: &str) -> Result<()> {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
let mut tmp_os: OsString = target.as_os_str().to_os_string();
tmp_os.push(".tmp");
let tmp = PathBuf::from(tmp_os);
std::fs::write(&tmp, content).map_err(|e| Error::Io {
path: tmp.clone(),
source: e,
})?;
std::fs::rename(&tmp, target).map_err(|e| Error::Io {
path: target.to_path_buf(),
source: e,
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{IdRule, IdentityConfig, KindRule, KindsConfig, NamingRule, RulesConfig};
use crate::model::{Kind, Node, Status};
use indexmap::IndexMap;
fn adr_config() -> Config {
Config {
kinds: KindsConfig {
allowed: vec!["adr".into(), "guide".into()],
},
identity: IdentityConfig {
kind_rules: vec![KindRule {
glob: "docs/decisions/**".into(),
kind: "adr".into(),
}],
id_rules: vec![IdRule {
kind: "adr".into(),
glob: None,
template: "adr-{stem}".into(),
}],
},
rules: RulesConfig {
naming: vec![NamingRule {
glob: "docs/decisions/**".into(),
pattern: r"^\d{4}-[a-z0-9-]+\.md$".into(),
sequential: true,
unique: true,
}],
},
..Config::default()
}
}
fn empty_graph() -> Graph {
Graph::new(IndexMap::new(), vec![])
}
#[test]
fn infers_sequential_filename_from_empty_graph() {
let result = scaffold(
Path::new("/tmp"),
ScaffoldSpec {
kind: Kind::new("adr"),
title: "Retry policy".into(),
id: None,
path: None,
},
&empty_graph(),
&adr_config(),
false,
false,
)
.unwrap();
assert_eq!(
result.path.to_string_lossy().replace('\\', "/"),
"docs/decisions/0001-retry-policy.md"
);
assert_eq!(result.id, "adr-0001-retry-policy");
assert!(!result.written);
}
#[test]
fn increments_sequence_from_existing_nodes() {
let mut map = IndexMap::new();
map.insert(
"adr-0003-auth".into(),
Node {
id: "adr-0003-auth".into(),
path: PathBuf::from("docs/decisions/0003-auth.md"),
title: "Auth".into(),
kind: Kind::new("adr"),
status: Status::new("active"),
created: None,
updated: None,
reviewed: None,
owner: None,
supersedes: vec![],
superseded_by: None,
implements: vec![],
related: vec![],
tags: vec![],
orphan_ok: true,
attrs: Default::default(),
},
);
let graph = Graph::new(map, vec![]);
let result = scaffold(
Path::new("/tmp"),
ScaffoldSpec {
kind: Kind::new("adr"),
title: "Cache eviction".into(),
id: None,
path: None,
},
&graph,
&adr_config(),
false,
false,
)
.unwrap();
assert_eq!(
result.path.to_string_lossy().replace('\\', "/"),
"docs/decisions/0004-cache-eviction.md"
);
}
#[test]
fn rejects_unknown_kind() {
let err = scaffold(
Path::new("/tmp"),
ScaffoldSpec {
kind: Kind::new("wat"),
title: "x".into(),
id: None,
path: None,
},
&empty_graph(),
&adr_config(),
false,
false,
)
.unwrap_err();
assert!(matches!(err, Error::Config(_)));
}
#[test]
fn explicit_path_bypasses_kind_rule() {
let config = Config {
kinds: KindsConfig {
allowed: vec!["note".into()],
},
..Config::default()
};
let result = scaffold(
Path::new("/tmp"),
ScaffoldSpec {
kind: Kind::new("note"),
title: "Hello".into(),
id: Some("note-hello".into()),
path: Some(PathBuf::from("misc/hello.md")),
},
&empty_graph(),
&config,
false,
false,
)
.unwrap();
assert_eq!(result.path.to_string_lossy(), "misc/hello.md");
assert_eq!(result.id, "note-hello");
}
#[test]
fn slugify_basics() {
assert_eq!(slugify("Hello, World!"), "hello-world");
assert_eq!(slugify(" multiple spaces "), "multiple-spaces");
assert_eq!(slugify("cache_eviction-v2"), "cache-eviction-v2");
}
#[test]
fn directory_from_glob_handles_literals() {
assert_eq!(
directory_from_glob("docs/decisions/**"),
Some(PathBuf::from("docs/decisions"))
);
assert_eq!(directory_from_glob("**/SKILL.md"), None);
}
#[test]
fn rule_targets_directory_common_shapes() {
assert!(rule_targets_directory(
"docs/decisions/**",
"docs/decisions"
));
assert!(rule_targets_directory(
"docs/decisions/*.md",
"docs/decisions"
));
assert!(rule_targets_directory(
"docs/decisions/[0-9]*.md",
"docs/decisions"
));
assert!(rule_targets_directory(
"docs/decisions/?*",
"docs/decisions"
));
assert!(rule_targets_directory("docs/*/decisions/**", "docs"));
assert!(!rule_targets_directory("docs/guides/**", "docs/decisions"));
assert!(!rule_targets_directory("**/SKILL.md", "docs/decisions"));
}
#[test]
fn scaffold_rejects_non_md_extension() {
let config = Config {
kinds: KindsConfig {
allowed: vec!["note".into()],
},
..Config::default()
};
let err = scaffold(
Path::new("/tmp"),
ScaffoldSpec {
kind: Kind::new("note"),
title: "x".into(),
id: Some("note-x".into()),
path: Some(PathBuf::from("misc/hello.txt")),
},
&empty_graph(),
&config,
false,
false,
)
.unwrap_err();
match err {
Error::Config(msg) => assert!(msg.contains(".md"), "{msg}"),
_ => panic!("expected Config error"),
}
}
#[test]
fn write_atomic_preserves_dotted_basename() {
let tmpdir =
std::env::temp_dir().join(format!("nodex-scaffold-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmpdir);
std::fs::create_dir_all(&tmpdir).unwrap();
let target = tmpdir.join("0001-v1.2.md");
write_atomic(&target, "hello").unwrap();
assert!(target.exists());
assert_eq!(std::fs::read_to_string(&target).unwrap(), "hello");
let leftovers: Vec<_> = std::fs::read_dir(&tmpdir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(leftovers.is_empty());
std::fs::remove_dir_all(&tmpdir).ok();
}
}