use std::path::PathBuf;
pub(crate) fn is_work_like(kind: &'static crate::entity::Kind) -> bool {
matches!(
kind.prefix,
"SL" | "ISS" | "IMP" | "CHR" | "RSK" | "IDE" | "REV"
)
}
pub(crate) fn is_record(kind: &'static crate::entity::Kind) -> bool {
crate::kinds::is_record(kind.prefix)
}
pub(crate) fn is_admissible_dep_target(kind: &'static crate::entity::Kind) -> bool {
is_work_like(kind) || is_record(kind)
}
fn resolve_dep_seq_src_path(root: &std::path::Path, source: &str) -> anyhow::Result<PathBuf> {
let (skref, sid) = crate::integrity::parse_canonical_ref(source)?;
anyhow::ensure!(
is_work_like(skref.kind),
"`{source}` is a {} entity, which cannot author needs/after — only a slice or a backlog item (issue/improvement/chore/risk/idea) carries dep/seq",
skref.kind.prefix
);
Ok(crate::entity::id_path(
root,
skref.kind,
sid,
crate::entity::Ext::Toml,
))
}
fn resolve_dep_seq_src(
root: &std::path::Path,
source: &str,
target: &str,
) -> anyhow::Result<PathBuf> {
let toml_path = resolve_dep_seq_src_path(root, source)?;
let (skref, sid) = crate::integrity::parse_canonical_ref(source)?;
let (tkref, tid) = crate::integrity::parse_canonical_ref(target)?;
crate::integrity::ensure_ref_resolves(root, target)?;
anyhow::ensure!(
is_admissible_dep_target(tkref.kind),
"`{target}` is a {} entity — needs/after may only target work (a slice or a backlog item) or a knowledge record (assumption/decision/question/constraint/evidence/hypothesis); governance docs are excluded",
tkref.kind.prefix
);
anyhow::ensure!(
!(skref.kind.prefix == tkref.kind.prefix && sid == tid),
"a {source} edge to itself is not a dependency — self-edges are refused"
);
Ok(toml_path)
}
pub(crate) fn run_needs_edge(
path: Option<PathBuf>,
source: &str,
target: &str,
) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let toml_path = resolve_dep_seq_src(&root, source, target)?;
crate::dep_seq::append(
&toml_path,
&crate::dep_seq::RelEdit::Needs(&[target.to_string()]),
)?;
writeln!(std::io::stdout(), "{source} needs {target}")?;
Ok(())
}
pub(crate) fn run_after_edge(
path: Option<PathBuf>,
source: &str,
target: &str,
rank: i32,
) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let toml_path = resolve_dep_seq_src(&root, source, target)?;
crate::dep_seq::append(
&toml_path,
&crate::dep_seq::RelEdit::After { to: target, rank },
)?;
let suffix = if rank == 0 {
String::new()
} else {
format!(" (rank {rank})")
};
writeln!(std::io::stdout(), "{source} after {target}{suffix}")?;
Ok(())
}
pub(crate) fn run_after_remove(
path: Option<PathBuf>,
source: &str,
target: &str,
rank: i32,
) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let toml_path = resolve_dep_seq_src(&root, source, target)?;
let ceiling = if rank == 0 { None } else { Some(rank) };
let removed = crate::dep_seq::remove(&toml_path, target, ceiling)?;
if removed == 0 {
anyhow::bail!("{source} has no after edge to {target}");
}
writeln!(
std::io::stdout(),
"{source} after {target} removed ({} edge{})",
removed,
if removed == 1 { "" } else { "s" }
)?;
Ok(())
}
pub(crate) fn run_after_prune(path: Option<PathBuf>, source: &str) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let toml_path = resolve_dep_seq_src_path(&root, source)?;
let ds = crate::dep_seq::read(&toml_path)?;
let mut dropped: Vec<(String, i32, String)> = Vec::new();
let mut to_drop: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for edge in &ds.after {
let is_dangling = match crate::integrity::parse_canonical_ref(&edge.to) {
Ok((kref, tid)) => {
let target_path =
crate::entity::id_path(&root, kref.kind, tid, crate::entity::Ext::Toml);
if target_path.exists() {
let body = std::fs::read_to_string(&target_path).unwrap_or_default();
let val: toml::Value = match toml::from_str(&body) {
Ok(v) => v,
Err(_) => toml::Value::Table(toml::Table::new()),
};
let status = val.get("status").and_then(|s| s.as_str()).unwrap_or("");
status == "resolved" || status == "closed"
} else {
true
}
}
Err(_) => true,
};
if is_dangling {
let reason = match crate::integrity::parse_canonical_ref(&edge.to) {
Ok((kref2, tid2)) => {
let target_path =
crate::entity::id_path(&root, kref2.kind, tid2, crate::entity::Ext::Toml);
if target_path.exists() {
let body = std::fs::read_to_string(&target_path).unwrap_or_default();
let val: toml::Value = match toml::from_str(&body) {
Ok(v) => v,
Err(_) => toml::Value::Table(toml::Table::new()),
};
let status = val.get("status").and_then(|s| s.as_str()).unwrap_or("");
let resolution =
val.get("resolution").and_then(|s| s.as_str()).unwrap_or("");
if resolution.is_empty() {
status.to_string()
} else {
format!("{status}/{resolution}")
}
} else {
"absent".to_string()
}
}
Err(_) => "absent (unparseable ref)".to_string(),
};
dropped.push((edge.to.clone(), edge.rank, reason));
to_drop.insert(edge.to.clone());
}
}
if dropped.is_empty() {
writeln!(std::io::stdout(), "{source}: nothing to prune")?;
return Ok(());
}
for target in &to_drop {
let _ = crate::dep_seq::remove(&toml_path, target, None)?;
}
for (target, rank, reason) in &dropped {
writeln!(
std::io::stdout(),
"{source} after {target} (rank {rank}) dropped (dangling: {reason})"
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::integrity;
use crate::slice;
#[test]
fn is_record_predicate_matches_kinds_record() {
let mut from_pred: Vec<&str> = crate::integrity::KINDS
.iter()
.filter(|k| is_record(k.kind))
.map(|k| k.kind.prefix)
.collect();
from_pred.sort_unstable();
let mut want: Vec<&str> = crate::kinds::RECORD.to_vec();
want.sort_unstable();
assert_eq!(from_pred, want);
}
#[test]
fn is_admissible_dep_target_is_work_like_plus_records() {
let admissible: &[&str] = &[
"SL", "ISS", "IMP", "CHR", "RSK", "IDE", "REV", "ASM", "DEC", "QUE", "CON", "EVD",
"HYP",
];
for k in integrity::KINDS
.iter()
.filter(|k| admissible.contains(&k.kind.prefix))
{
assert!(
is_admissible_dep_target(k.kind),
"{} is admissible as dep target",
k.kind.prefix
);
}
for k in integrity::KINDS
.iter()
.filter(|k| !admissible.contains(&k.kind.prefix))
{
assert!(
!is_admissible_dep_target(k.kind),
"{} must NOT be admissible as dep target",
k.kind.prefix
);
}
}
#[test]
fn resolve_dep_seq_src_accepts_record_target() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".doctrine")).unwrap();
std::fs::write(root.join(crate::dtoml::DOCTRINE_TOML), "").unwrap();
seed_sl_toml(root, 1);
seed_record_toml(root, "question", "QUE", 1, "open");
let path = resolve_dep_seq_src(root, "SL-001", "QUE-001");
assert!(
path.is_ok(),
"SL needs QUE should be accepted, got: {path:?}"
);
}
#[test]
fn resolve_dep_seq_src_refuses_governance_target() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".doctrine")).unwrap();
std::fs::write(root.join(crate::dtoml::DOCTRINE_TOML), "").unwrap();
seed_sl_toml(root, 1);
seed_adr_toml(root, 1);
let err = resolve_dep_seq_src(root, "SL-001", "ADR-001").unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("ADR") && msg.contains("governance"),
"governance target should be refused with mention of governance, got: {msg}"
);
}
#[test]
fn resolve_dep_seq_src_refuses_record_source() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".doctrine")).unwrap();
std::fs::write(root.join(crate::dtoml::DOCTRINE_TOML), "").unwrap();
seed_record_toml(root, "question", "QUE", 1, "open");
seed_sl_toml(root, 1);
let err = resolve_dep_seq_src(root, "QUE-001", "SL-001").unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("QUE") && msg.contains("cannot author"),
"record source should be refused, got: {msg}"
);
}
#[test]
fn sl_needs_open_que_is_blocked() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".doctrine")).unwrap();
std::fs::write(root.join(crate::dtoml::DOCTRINE_TOML), "").unwrap();
seed_sl_toml(root, 1);
seed_record_toml(root, "question", "QUE", 1, "open");
run_needs_edge(Some(root.to_path_buf()), "SL-001", "QUE-001").unwrap();
let sl_toml =
std::fs::read_to_string(root.join(".doctrine/slice/001/slice-001.toml")).unwrap();
assert!(
sl_toml.contains("QUE-001"),
"SL-001 should reference QUE-001"
);
}
#[test]
fn sl_needs_answered_que_is_unblocked() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".doctrine")).unwrap();
std::fs::write(root.join(crate::dtoml::DOCTRINE_TOML), "").unwrap();
seed_sl_toml(root, 1);
seed_record_toml(root, "question", "QUE", 1, "answered");
run_needs_edge(Some(root.to_path_buf()), "SL-001", "QUE-001").unwrap();
let sl_toml =
std::fs::read_to_string(root.join(".doctrine/slice/001/slice-001.toml")).unwrap();
assert!(
sl_toml.contains("QUE-001"),
"SL-001 should reference QUE-001"
);
}
fn seed_record_toml(
root: &std::path::Path,
kind_dir: &str,
prefix: &str,
id: u32,
status: &str,
) {
let padded = format!("{id:03}");
let dir = root
.join(".doctrine")
.join("knowledge")
.join(kind_dir)
.join(&padded);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("record-{padded}.toml")),
format!(
"id = {id}\nslug = \"r{padded}\"\ntitle = \"Test {prefix}\"\n\
status = \"{status}\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
),
)
.unwrap();
}
fn seed_sl_toml(root: &std::path::Path, id: u32) {
let padded = format!("{id:03}");
let dir = root.join(".doctrine").join("slice").join(&padded);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("slice-{padded}.toml")),
format!(
"id = {id}\nslug = \"s{padded}\"\ntitle = \"Test S{padded}\"\n\
status = \"proposed\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\nneeds = []\n",
),
)
.unwrap();
}
fn seed_adr_toml(root: &std::path::Path, id: u32) {
let padded = format!("{id:03}");
let dir = root.join(".doctrine").join("adr").join(&padded);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("adr-{padded}.toml")),
format!(
"id = {id}\nslug = \"a{padded}\"\ntitle = \"Test A{padded}\"\n\
status = \"accepted\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
),
)
.unwrap();
}
#[test]
fn is_work_like_is_exactly_slice_plus_backlog_plus_revision() {
assert!(is_work_like(&slice::SLICE_KIND));
for k in integrity::KINDS
.iter()
.filter(|k| matches!(k.kind.prefix, "ISS" | "IMP" | "CHR" | "RSK" | "IDE" | "REV"))
{
assert!(is_work_like(k.kind), "{} is work-like", k.kind.prefix);
}
for k in integrity::KINDS.iter().filter(|k| {
!matches!(
k.kind.prefix,
"SL" | "ISS" | "IMP" | "CHR" | "RSK" | "IDE" | "REV"
)
}) {
assert!(
!is_work_like(k.kind),
"{} must NOT be work-like (off the allowlist)",
k.kind.prefix
);
}
}
}