use std::collections::BTreeMap;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use crate::adr::ADR_KIND;
use crate::backlog::{CHORE_KIND, IDEA_KIND, IMPROVEMENT_KIND, ISSUE_KIND, RISK_KIND};
use crate::knowledge::{ASSUMPTION_KIND, CONSTRAINT_KIND, DECISION_KIND, QUESTION_KIND};
use crate::policy::POLICY_KIND;
use crate::rec::REC_KIND;
use crate::requirement::REQUIREMENT_KIND;
use crate::review::REVIEW_KIND;
use crate::slice::SLICE_KIND;
use crate::spec::{PRODUCT_SPEC_KIND, TECH_SPEC_KIND};
use crate::standard::STANDARD_KIND;
use crate::{entity, fsutil, git, listing, meta, root};
pub(crate) struct KindRef {
pub(crate) kind: &'static entity::Kind,
pub(crate) stem: &'static str,
pub(crate) state_dir: Option<&'static str>,
}
pub(crate) const KINDS: &[KindRef] = &[
KindRef {
kind: &SLICE_KIND,
stem: "slice",
state_dir: Some(".doctrine/state/slice"),
},
KindRef {
kind: &ADR_KIND.kind,
stem: "adr",
state_dir: None,
},
KindRef {
kind: &POLICY_KIND.kind,
stem: "policy",
state_dir: None,
},
KindRef {
kind: &STANDARD_KIND.kind,
stem: "standard",
state_dir: None,
},
KindRef {
kind: &PRODUCT_SPEC_KIND,
stem: "spec",
state_dir: None,
},
KindRef {
kind: &TECH_SPEC_KIND,
stem: "spec",
state_dir: None,
},
KindRef {
kind: &REQUIREMENT_KIND,
stem: "requirement",
state_dir: None,
},
KindRef {
kind: &ISSUE_KIND,
stem: "backlog",
state_dir: None,
},
KindRef {
kind: &IMPROVEMENT_KIND,
stem: "backlog",
state_dir: None,
},
KindRef {
kind: &CHORE_KIND,
stem: "backlog",
state_dir: None,
},
KindRef {
kind: &RISK_KIND,
stem: "backlog",
state_dir: None,
},
KindRef {
kind: &IDEA_KIND,
stem: "backlog",
state_dir: None,
},
KindRef {
kind: &REVIEW_KIND,
stem: "review",
state_dir: Some(".doctrine/state/review"),
},
KindRef {
kind: &REC_KIND,
stem: "rec",
state_dir: None,
},
KindRef {
kind: &ASSUMPTION_KIND,
stem: "record",
state_dir: None,
},
KindRef {
kind: &DECISION_KIND,
stem: "record",
state_dir: None,
},
KindRef {
kind: &QUESTION_KIND,
stem: "record",
state_dir: None,
},
KindRef {
kind: &CONSTRAINT_KIND,
stem: "record",
state_dir: None,
},
];
struct EntityFacts {
dir_id: u32,
toml_id: u32,
}
struct AliasFacts {
encoded_id: u32,
target_toml_id: Option<u32>,
}
struct KindSnapshot {
prefix: &'static str,
entities: Vec<EntityFacts>,
aliases: Vec<AliasFacts>,
}
struct Finding(String);
impl std::fmt::Display for Finding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
fn check_kind(snap: &KindSnapshot) -> Vec<Finding> {
let p = snap.prefix;
let mut findings = Vec::new();
for e in &snap.entities {
if e.dir_id != e.toml_id {
findings.push(Finding(format!(
"{p}: dir {:03} declares id {:03} (basename ≠ toml id)",
e.dir_id, e.toml_id
)));
}
}
let mut by_id: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
for e in &snap.entities {
by_id.entry(e.toml_id).or_default().push(e.dir_id);
}
for (id, mut dirs) in by_id {
if dirs.len() > 1 {
dirs.sort_unstable();
let dirs = dirs
.iter()
.map(|d| format!("{d:03}"))
.collect::<Vec<_>>()
.join(", ");
findings.push(Finding(format!(
"{p}: id {id:03} declared by dirs {dirs} (intra-kind duplicate)"
)));
}
}
for a in &snap.aliases {
if a.target_toml_id != Some(a.encoded_id) {
let got = a.target_toml_id.map_or_else(
|| "no numbered target".to_string(),
|t| format!("id {t:03}"),
);
findings.push(Finding(format!(
"{p}: alias {:03}-* targets {got} (expected id {:03})",
a.encoded_id, a.encoded_id
)));
}
}
findings
}
fn scan_kind(root: &Path, kind: &'static KindRef) -> anyhow::Result<KindSnapshot> {
let tree_root = root.join(kind.kind.dir);
let mut entities = Vec::new();
for dir_id in entity::scan_ids(&tree_root)? {
let toml_id = meta::read_id(&tree_root, kind.stem, dir_id)?;
entities.push(EntityFacts { dir_id, toml_id });
}
let aliases = scan_aliases(&tree_root, kind.stem)?;
Ok(KindSnapshot {
prefix: kind.kind.prefix,
entities,
aliases,
})
}
fn scan_aliases(tree_root: &Path, stem: &str) -> anyhow::Result<Vec<AliasFacts>> {
let mut aliases = Vec::new();
let entries = match std::fs::read_dir(tree_root) {
Ok(e) => e,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(aliases),
Err(e) => return Err(e).with_context(|| format!("read {}", tree_root.display())),
};
for entry in entries {
let entry = entry?;
if !entry.file_type()?.is_symlink() {
continue;
}
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Some((head, _)) = name.split_once('-') else {
continue;
};
let Ok(encoded_id) = head.parse::<u32>() else {
continue;
};
let target_toml_id = std::fs::read_link(entry.path())
.ok()
.and_then(|t| t.file_name().and_then(|b| b.to_str()?.parse::<u32>().ok()))
.and_then(|target_dir_id| meta::read_id(tree_root, stem, target_dir_id).ok());
aliases.push(AliasFacts {
encoded_id,
target_toml_id,
});
}
Ok(aliases)
}
pub(crate) fn id_integrity_findings(root: &Path) -> anyhow::Result<Vec<String>> {
let mut findings = Vec::new();
for kind in KINDS {
findings.extend(check_kind(&scan_kind(root, kind)?).into_iter().map(|f| f.0));
}
Ok(findings)
}
pub(crate) fn scanned_kinds() -> String {
KINDS
.iter()
.map(|k| k.kind.prefix)
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn kind_by_prefix(prefix: &str) -> Option<&'static KindRef> {
KINDS.iter().find(|k| k.kind.prefix == prefix)
}
pub(crate) fn ensure_ref_resolves(root: &Path, reference: &str) -> anyhow::Result<()> {
let (kind, id) = parse_canonical_ref(reference)?;
let name = format!("{id:03}");
let dir = root.join(kind.kind.dir).join(&name);
anyhow::ensure!(
fsutil::is_real_dir(&dir),
"`{reference}` does not resolve to an entity (no {} at {})",
listing::canonical_id(kind.kind.prefix, id),
dir.display()
);
Ok(())
}
pub(crate) fn parse_canonical_ref(reference: &str) -> anyhow::Result<(&'static KindRef, u32)> {
let (prefix, num) = reference
.rsplit_once('-')
.with_context(|| format!("`{reference}` is not a canonical ref (expected e.g. SL-031)"))?;
let kind = kind_by_prefix(prefix)
.with_context(|| format!("unknown kind prefix `{prefix}` in `{reference}`"))?;
let id = num
.parse::<u32>()
.with_context(|| format!("`{num}` is not a numeric id in `{reference}`"))?;
Ok((kind, id))
}
pub(crate) fn run_reseat(
path: Option<PathBuf>,
reference: &str,
to: Option<u32>,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let (kind, src_id) = parse_canonical_ref(reference)?;
let tree_root = root.join(kind.kind.dir);
let src_name = format!("{src_id:03}");
let src_dir = tree_root.join(&src_name);
anyhow::ensure!(
fsutil::is_real_dir(&src_dir),
"no {} at {}",
listing::canonical_id(kind.kind.prefix, src_id),
src_dir.display()
);
let slug = meta::read_meta(&tree_root, kind.stem, src_id)?.slug;
let dst_id = match to {
Some(t) => t,
None => entity::next_id(
&entity::scan_ids(&tree_root)?,
&git::trunk_entity_ids(&root, kind.kind.dir)?,
),
};
anyhow::ensure!(
dst_id != src_id,
"{} is already seated at {src_name}",
listing::canonical_id(kind.kind.prefix, src_id)
);
let dst_name = format!("{dst_id:03}");
let dst_dir = tree_root.join(&dst_name);
anyhow::ensure!(
!dst_dir.exists(),
"id {dst_name} is occupied — refusing to clobber {}",
dst_dir.display()
);
if let Some(state_dir) = kind.state_dir {
let state = root.join(state_dir).join(&src_name);
anyhow::ensure!(
!state.exists(),
"{} has live runtime phase state at {} — clear it first (reseat does not own the disposable tier)",
listing::canonical_id(kind.kind.prefix, src_id),
state.display()
);
}
std::fs::rename(&src_dir, &dst_dir)
.with_context(|| format!("rename {} → {}", src_dir.display(), dst_dir.display()))?;
for ext in ["toml", "md"] {
let from = dst_dir.join(format!("{}-{src_name}.{ext}", kind.stem));
let onto = dst_dir.join(format!("{}-{dst_name}.{ext}", kind.stem));
if from.exists() {
std::fs::rename(&from, &onto)
.with_context(|| format!("rename {} → {}", from.display(), onto.display()))?;
}
}
let toml_path = dst_dir.join(format!("{}-{dst_name}.toml", kind.stem));
let text = std::fs::read_to_string(&toml_path)
.with_context(|| format!("read {}", toml_path.display()))?;
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("parse {}", toml_path.display()))?;
doc.as_table_mut()
.insert("id", toml_edit::value(i64::from(dst_id)));
std::fs::write(&toml_path, doc.to_string())
.with_context(|| format!("write {}", toml_path.display()))?;
let old_alias = tree_root.join(format!("{src_name}-{slug}"));
if matches!(std::fs::symlink_metadata(&old_alias), Ok(m) if m.file_type().is_symlink()) {
std::fs::remove_file(&old_alias)
.with_context(|| format!("remove stale alias {}", old_alias.display()))?;
}
fsutil::set_symlink(
&tree_root.join(format!("{dst_name}-{slug}")),
Path::new(&dst_name),
)?;
let old_ref = listing::canonical_id(kind.kind.prefix, src_id);
let new_ref = listing::canonical_id(kind.kind.prefix, dst_id);
writeln!(io::stdout(), "reseated {old_ref} → {new_ref}")?;
let danglers = scan_danglers(&root, &old_ref)?;
if danglers.is_empty() {
return Ok(());
}
writeln!(
io::stdout(),
"inbound citations to {old_ref} (rewrite by hand — prose relations are outbound-only):"
)?;
for d in &danglers {
writeln!(io::stdout(), " {d}")?;
}
bail!(
"reseat: {} inbound citation(s) to {old_ref} remain",
danglers.len()
)
}
fn scan_danglers(root: &Path, needle: &str) -> anyhow::Result<Vec<String>> {
let pattern = root.join(".doctrine/**/*.md");
let pattern = pattern
.to_str()
.with_context(|| format!("non-utf8 scan path {}", pattern.display()))?;
let mut hits = Vec::new();
for entry in glob::glob(pattern).context("bad glob pattern")? {
let path = entry.context("glob walk")?;
if is_disposable_prose(&path) {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue; };
for (i, line) in text.lines().enumerate() {
if line_cites(line, needle) {
hits.push(format!("{}:{}", path.display(), i + 1));
}
}
}
Ok(hits)
}
fn is_disposable_prose(path: &Path) -> bool {
if path.file_name().and_then(|n| n.to_str()) == Some("handover.md") {
return true;
}
let comps: Vec<_> = path
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
comps.windows(2).any(|w| w == [".doctrine", "state"])
}
fn line_cites(line: &str, needle: &str) -> bool {
let mut base = 0;
while let Some(rest) = line.get(base..)
&& let Some(pos) = rest.find(needle)
{
let i = base + pos;
let before_ok = line
.get(..i)
.and_then(|s| s.chars().next_back())
.is_none_or(|c| !c.is_ascii_alphanumeric());
let after = i + needle.len();
let after_ok = line
.get(after..)
.and_then(|s| s.chars().next())
.is_none_or(|c| !c.is_ascii_alphanumeric());
if before_ok && after_ok {
return true;
}
base = i + 1;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn snap(entities: Vec<(u32, u32)>, aliases: Vec<(u32, Option<u32>)>) -> KindSnapshot {
KindSnapshot {
prefix: "SL",
entities: entities
.into_iter()
.map(|(dir_id, toml_id)| EntityFacts { dir_id, toml_id })
.collect(),
aliases: aliases
.into_iter()
.map(|(encoded_id, target_toml_id)| AliasFacts {
encoded_id,
target_toml_id,
})
.collect(),
}
}
#[test]
fn clean_kind_yields_no_findings() {
let s = snap(vec![(1, 1), (2, 2)], vec![(1, Some(1)), (2, Some(2))]);
assert!(check_kind(&s).is_empty());
}
#[test]
fn rule_a_flags_dir_id_mismatch() {
let s = snap(vec![(3, 45)], vec![]);
let f = check_kind(&s);
assert_eq!(f.len(), 1);
assert!(
f[0].to_string().contains("dir 003 declares id 045"),
"{}",
f[0]
);
}
#[test]
fn rule_b_flags_intra_kind_duplicate_id() {
let s = snap(vec![(7, 7), (8, 7)], vec![]);
let f = check_kind(&s);
let dup = f
.iter()
.find(|x| x.to_string().contains("intra-kind duplicate"));
let dup = dup.expect("a duplicate finding");
assert!(dup.to_string().contains("007, 008"), "{dup}");
}
#[test]
fn rule_c_flags_mis_targeted_alias() {
let s = snap(vec![], vec![(31, Some(45))]);
let f = check_kind(&s);
assert_eq!(f.len(), 1);
assert!(
f[0].to_string().contains("alias 031-* targets id 045"),
"{}",
f[0]
);
}
#[test]
fn rule_c_flags_dangling_alias() {
let s = snap(vec![], vec![(31, None)]);
let f = check_kind(&s);
assert_eq!(f.len(), 1);
assert!(f[0].to_string().contains("no numbered target"), "{}", f[0]);
}
#[test]
fn scan_kind_reads_a_review_statusless_toml() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let dir = root.join(REVIEW_KIND.dir).join("001");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("review-001.toml"),
"id = 1\nslug = \"s\"\ntitle = \"T\"\n\n[review]\nfacet = \"design\"\n",
)
.unwrap();
let review_kind = kind_by_prefix("RV").expect("RV in KINDS");
let snap = scan_kind(root, review_kind).expect("status-less review scans cleanly");
assert_eq!(snap.entities.len(), 1);
assert_eq!(snap.entities[0].toml_id, 1);
}
#[test]
fn ensure_ref_resolves_guards_the_forward_edge() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let dir = root.join(".doctrine/slice/024");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("slice-024.toml"), "id = 24\n").unwrap();
assert!(ensure_ref_resolves(root, "SL-024").is_ok());
let dangling = ensure_ref_resolves(root, "SL-099").unwrap_err();
assert!(
dangling.to_string().contains("does not resolve"),
"{dangling}"
);
let unknown = ensure_ref_resolves(root, "ZZ-001").unwrap_err();
assert!(
unknown.to_string().contains("unknown kind prefix"),
"{unknown}"
);
}
#[test]
fn parse_canonical_ref_resolves_kind_and_id() {
let (kind, id) = parse_canonical_ref("SL-031").expect("valid ref");
assert_eq!(kind.kind.prefix, "SL");
assert_eq!(id, 31);
assert!(
parse_canonical_ref("031").is_err(),
"bare id is not canonical"
);
assert!(
parse_canonical_ref("ZZ-001").is_err(),
"unknown prefix rejected"
);
assert!(
parse_canonical_ref("SL-x").is_err(),
"non-numeric id rejected"
);
}
#[test]
fn line_cites_matches_whole_token_only() {
assert!(line_cites("see SL-031 for detail", "SL-031"));
assert!(line_cites("SL-031, ADR-004", "SL-031"));
assert!(line_cites("SL-031", "SL-031"));
assert!(!line_cites("SL-0310 is different", "SL-031"));
assert!(!line_cites("XSL-031", "SL-031"));
assert!(!line_cites("SL-031x is not a ref", "SL-031")); assert!(!line_cites("nothing here", "SL-031"));
}
#[test]
fn scan_danglers_skips_disposable_prose() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let plant = |rel: &str| {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, "cites SL-031 here\n").unwrap();
};
plant(".doctrine/notes/x.md"); plant(".doctrine/slice/001/handover.md"); plant(".doctrine/state/slice/001/phases/phase-01.md");
let hits = scan_danglers(root, "SL-031").unwrap();
assert_eq!(hits.len(), 1, "only authored prose reported: {hits:?}");
assert!(hits[0].ends_with("notes/x.md:1"), "{}", hits[0]);
}
#[test]
fn kinds_table_covers_the_numbered_kinds() {
let prefixes: Vec<_> = KINDS.iter().map(|k| k.kind.prefix).collect();
assert_eq!(
prefixes,
[
"SL", "ADR", "POL", "STD", "PRD", "SPEC", "REQ", "ISS", "IMP", "CHR", "RSK", "IDE",
"RV", "REC", "ASM", "DEC", "QUE", "CON"
]
);
let stateful: Vec<_> = KINDS
.iter()
.filter(|k| k.state_dir.is_some())
.map(|k| k.kind.prefix)
.collect();
assert_eq!(stateful, ["SL", "RV"]);
}
#[test]
fn kinds_prefixes_are_corpus_wide_disjoint() {
use std::collections::BTreeSet;
let prefixes: Vec<_> = KINDS.iter().map(|k| k.kind.prefix).collect();
let distinct: BTreeSet<_> = prefixes.iter().copied().collect();
assert_eq!(
distinct.len(),
prefixes.len(),
"all KINDS prefixes are distinct: {prefixes:?}"
);
}
}