use std::path::PathBuf;
use std::str::FromStr;
use clap::Subcommand;
use crate::catalog::diagnostic::Severity;
use crate::catalog::scan::ScanMode;
use crate::listing::Format;
use crate::relation_query::ListFilter;
fn resolve_link_path(
root: &std::path::Path,
source: &str,
label: &str,
role: Option<crate::relation::Role>,
degree: Option<crate::relation::Degree>,
) -> anyhow::Result<(PathBuf, &'static crate::relation::RelationRule)> {
let (kref, id) = crate::integrity::parse_resolvable_ref(root, source)?;
let rule = crate::relation::validate_link(kref.kind, label, role, degree)?;
let toml_path = crate::entity::id_path(root, kref.kind, id, crate::entity::Ext::Toml);
Ok((toml_path, rule))
}
fn parse_role(role: Option<&str>) -> anyhow::Result<Option<crate::relation::Role>> {
role.map(|name| {
crate::relation::Role::from_name(name).ok_or_else(|| {
anyhow::anyhow!(
"`{name}` is not a known role (expected: implements, originates_from, concerns)"
)
})
})
.transpose()
}
fn parse_degree(degree: Option<&str>) -> anyhow::Result<Option<crate::relation::Degree>> {
degree
.map(|name| {
crate::relation::Degree::from_name(name).ok_or_else(|| {
anyhow::anyhow!("`{name}` is not a known degree (expected: full, partial)")
})
})
.transpose()
}
pub(crate) fn run_link(
path: Option<PathBuf>,
source: &str,
label: &str,
role: Option<&str>,
degree: Option<&str>,
target: &str,
) -> anyhow::Result<()> {
use anyhow::Context;
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let role = parse_role(role)?;
let degree = parse_degree(degree)?;
if let Ok(mref) = crate::memory::MemoryRef::parse(source) {
anyhow::ensure!(
role.is_none(),
"memory relations do not take a role; remove `--role`"
);
let toml_path = crate::memory::resolve_memory_toml_path(&root, &mref)?;
if crate::integrity::parse_canonical_ref(target).is_ok() || target.parse::<u32>().is_ok() {
crate::integrity::ensure_ref_resolves(&root, target).with_context(|| {
format!("target `{target}` does not resolve to an existing entity")
})?;
}
let outcome = crate::memory::append_memory_relation(&toml_path, label, target)?;
match outcome {
crate::relation::AppendOutcome::Wrote => {
writeln!(std::io::stdout(), "linked: {source} {label} {target}")?;
}
crate::relation::AppendOutcome::Noop => {
writeln!(
std::io::stdout(),
"already linked: {source} {label} {target}"
)?;
}
}
return Ok(());
}
let (toml_path, rule) = resolve_link_path(&root, source, label, role, degree)?;
if !matches!(rule.target, crate::relation::TargetSpec::Unvalidated) {
let (tkref, _tid) = crate::integrity::parse_resolvable_ref(&root, target)?;
let (skref, _sid) = crate::integrity::parse_resolvable_ref(&root, source)?;
crate::relation::check_target_kind(rule, skref.kind, tkref.kind.prefix)?;
}
let outcome = crate::relation::append_edge(&toml_path, rule.label, rule.role, degree, target)?;
match outcome {
crate::relation::AppendOutcome::Wrote => {
writeln!(std::io::stdout(), "linked: {source} {label} {target}")?;
}
crate::relation::AppendOutcome::Noop => {
writeln!(
std::io::stdout(),
"already linked: {source} {label} {target}"
)?;
}
}
Ok(())
}
#[derive(Subcommand)]
pub(crate) enum RelationCommand {
List {
#[arg(long)]
include_memory: bool,
#[arg(long)]
label: Option<String>,
#[arg(long)]
target: Option<String>,
#[arg(long = "source-kind")]
source_kind: Option<String>,
#[arg(long)]
unresolved: bool,
#[arg(long, value_parser = Format::from_str)]
format: Option<Format>,
#[arg(long)]
json: bool,
#[arg(long, value_delimiter = ',')]
columns: Option<Vec<String>>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Census {
#[arg(long)]
include_memory: bool,
#[arg(long, value_parser = Format::from_str)]
format: Option<Format>,
#[arg(long)]
json: bool,
#[arg(long, value_delimiter = ',')]
columns: Option<Vec<String>>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[expect(
clippy::too_many_arguments,
reason = "clap dispatch flatten — 9 fields from subcommand"
)]
pub(crate) fn run_relation_list(
path: Option<PathBuf>,
include_memory: bool,
label: Option<String>,
target: Option<String>,
source_kind: Option<String>,
unresolved: bool,
format: Option<Format>,
json: bool,
columns: Option<&[String]>,
) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let catalog = crate::catalog::hydrate::scan_catalog(&root, ScanMode::default())?;
emit_diagnostics(&root, &catalog.diagnostics)?;
let resolved_format = if json {
Format::Json
} else {
format.unwrap_or(Format::Table)
};
let opts = crate::listing::RenderOpts {
color: crate::tty::stdout_color_enabled(),
term_width: crate::tty::stdout_terminal_width(),
};
let filter = ListFilter {
include_memory,
label,
target,
source_kind,
unresolved,
};
let rows = crate::relation_query::project_list(&catalog, &filter);
let out = crate::relation_query::render_list(&rows, resolved_format, opts, columns)?;
write!(std::io::stdout(), "{out}")?;
Ok(())
}
pub(crate) fn run_relation_census(
path: Option<PathBuf>,
include_memory: bool,
format: Option<Format>,
json: bool,
columns: Option<&[String]>,
) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let catalog = crate::catalog::hydrate::scan_catalog(&root, ScanMode::default())?;
emit_diagnostics(&root, &catalog.diagnostics)?;
let resolved_format = if json {
Format::Json
} else {
format.unwrap_or(Format::Table)
};
let opts = crate::listing::RenderOpts {
color: crate::tty::stdout_color_enabled(),
term_width: crate::tty::stdout_terminal_width(),
};
let rows = crate::relation_query::project_census(&catalog, include_memory);
let out = crate::relation_query::render_census(&rows, resolved_format, opts, columns)?;
write!(std::io::stdout(), "{out}")?;
Ok(())
}
fn emit_diagnostics(
root: &std::path::Path,
diagnostics: &[crate::catalog::diagnostic::CatalogDiagnostic],
) -> anyhow::Result<()> {
use std::io::Write;
let mut dropped_edges: usize = 0;
for diag in diagnostics {
match diag.severity {
Severity::Error => {
let rel = diag.file.strip_prefix(root).unwrap_or(&diag.file);
writeln!(std::io::stderr(), "{}: {}", rel.display(), diag.message)?;
}
Severity::Warning => {
if diag.message.contains("empty relation") {
dropped_edges = dropped_edges.wrapping_add(1);
}
}
Severity::Info => {
}
}
}
if dropped_edges > 0 {
writeln!(
std::io::stderr(),
"{dropped_edges} edge(s) dropped — run `doctrine validate` for detail"
)?;
}
Ok(())
}
pub(crate) fn run_unlink(
path: Option<PathBuf>,
source: &str,
label: &str,
role: Option<&str>,
target: &str,
) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let role = parse_role(role)?;
if let Ok(mref) = crate::memory::MemoryRef::parse(source) {
anyhow::ensure!(
role.is_none(),
"memory relations do not take a role; remove `--role`"
);
let toml_path = crate::memory::resolve_memory_toml_path(&root, &mref)?;
let outcome = crate::memory::remove_memory_relation(&toml_path, label, target)?;
match outcome {
crate::relation::RemoveOutcome::Removed => {
writeln!(std::io::stdout(), "unlinked: {source} {label} {target}")?;
}
crate::relation::RemoveOutcome::Absent => {
writeln!(std::io::stdout(), "not linked: {source} {label} {target}")?;
}
}
return Ok(());
}
let (toml_path, rule) = resolve_link_path(&root, source, label, role, None)?;
let outcome = crate::relation::remove_edge(&toml_path, rule.label, rule.role, target)?;
match outcome {
crate::relation::RemoveOutcome::Removed => {
writeln!(std::io::stdout(), "unlinked: {source} {label} {target}")?;
}
crate::relation::RemoveOutcome::Absent => {
writeln!(std::io::stdout(), "not linked: {source} {label} {target}")?;
}
}
Ok(())
}
#[cfg(test)]
#[expect(clippy::unwrap_used, reason = "test code")]
mod tests {
use super::*;
const MEM_TEST_UID: &str = "mem_018f3a1b2c3d4e5f60718293a4b5c6d7";
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 = \"accepted\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\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();
}
fn seed_memory_toml(root: &std::path::Path, uid: &str, content: &str) {
let mem_dir = root.join(".doctrine/memory/items").join(uid);
std::fs::create_dir_all(&mem_dir).unwrap();
let body = if content.is_empty() {
format!(
"uid = \"{uid}\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
source = \"test\"\nrelevance = \"test\"\n"
)
} else {
content.to_string()
};
std::fs::write(mem_dir.join("memory.toml"), body).unwrap();
}
#[test]
fn link_supersedes_on_record_is_lifecycle_only() {
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_adr_toml(root, 1);
seed_adr_toml(root, 2);
run_link(
Some(root.to_path_buf()),
"ADR-001",
"related",
None,
None,
"ADR-002",
)
.unwrap();
let content = std::fs::read_to_string(root.join(".doctrine/adr/001/adr-001.toml")).unwrap();
assert!(content.contains("[[relation]]"));
assert!(content.contains("label = \"related\""));
}
#[test]
fn link_memory_uid_appends_relation_row() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_sl_toml(root, 1);
seed_memory_toml(root, MEM_TEST_UID, "");
run_link(
Some(root.to_path_buf()),
MEM_TEST_UID,
"related",
None,
None,
"SL-001",
)
.unwrap();
let content = std::fs::read_to_string(
root.join(format!(".doctrine/memory/items/{MEM_TEST_UID}/memory.toml")),
)
.unwrap();
assert!(content.contains("[[relation]]"));
assert!(content.contains("target = \"SL-001\""));
}
#[test]
fn link_memory_uid_repeat_is_noop() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_sl_toml(root, 1);
seed_memory_toml(root, MEM_TEST_UID, "");
run_link(
Some(root.to_path_buf()),
MEM_TEST_UID,
"related",
None,
None,
"SL-001",
)
.unwrap();
run_link(
Some(root.to_path_buf()),
MEM_TEST_UID,
"related",
None,
None,
"SL-001",
)
.unwrap();
let content = std::fs::read_to_string(
root.join(format!(".doctrine/memory/items/{MEM_TEST_UID}/memory.toml")),
)
.unwrap();
let count = content.matches("[[relation]]").count();
assert_eq!(count, 1, "should still have exactly one [[relation]] row");
}
#[test]
fn unlink_memory_uid_then_repeat() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_sl_toml(root, 1);
seed_memory_toml(root, MEM_TEST_UID, "");
run_link(
Some(root.to_path_buf()),
MEM_TEST_UID,
"related",
None,
None,
"SL-001",
)
.unwrap();
run_unlink(
Some(root.to_path_buf()),
MEM_TEST_UID,
"related",
None,
"SL-001",
)
.unwrap();
let result = run_unlink(
Some(root.to_path_buf()),
MEM_TEST_UID,
"related",
None,
"SL-001",
);
assert!(result.is_ok(), "second unlink should succeed as noop");
let content = std::fs::read_to_string(
root.join(format!(".doctrine/memory/items/{MEM_TEST_UID}/memory.toml")),
)
.unwrap();
assert!(!content.contains("[[relation]]"));
}
#[test]
fn link_memory_uid_bad_target_errors() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_memory_toml(root, MEM_TEST_UID, "");
let result = run_link(
Some(root.to_path_buf()),
MEM_TEST_UID,
"related",
None,
None,
"SL-999",
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("does not resolve"), "got: {err}");
}
#[test]
fn link_memory_uid_free_text_target_ok() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_memory_toml(root, MEM_TEST_UID, "");
run_link(
Some(root.to_path_buf()),
MEM_TEST_UID,
"related",
None,
None,
"https://example.com",
)
.unwrap();
let content = std::fs::read_to_string(
root.join(format!(".doctrine/memory/items/{MEM_TEST_UID}/memory.toml")),
)
.unwrap();
assert!(content.contains("target = \"https://example.com\""));
}
#[test]
fn link_numbered_entity_still_works() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_sl_toml(root, 1);
seed_sl_toml(root, 2);
run_link(
Some(root.to_path_buf()),
"SL-001",
"related",
None,
None,
"SL-002",
)
.unwrap();
let content =
std::fs::read_to_string(root.join(".doctrine/slice/001/slice-001.toml")).unwrap();
assert!(content.contains("[[relation]]"));
assert!(content.contains("target = \"SL-002\""));
}
#[test]
fn link_memory_key_appends_relation_row() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_sl_toml(root, 1);
seed_memory_toml(root, "mem.fact.cli.skinny", "");
run_link(
Some(root.to_path_buf()),
"mem.fact.cli.skinny",
"related",
None,
None,
"SL-001",
)
.unwrap();
let content = std::fs::read_to_string(
root.join(".doctrine/memory/items/mem.fact.cli.skinny/memory.toml"),
)
.unwrap();
assert!(content.contains("[[relation]]"), "relation row written");
assert!(content.contains("target = \"SL-001\""), "target present");
}
fn seed_spec_dir(root: &std::path::Path, reference: &str) {
let (kref, id) = crate::integrity::parse_canonical_ref(reference).unwrap();
let dir = root.join(kref.kind.dir).join(format!("{id:03}"));
std::fs::create_dir_all(&dir).unwrap();
}
fn sl_edges(root: &std::path::Path, id: u32) -> Vec<crate::relation::RelationEdge> {
let padded = format!("{id:03}");
let text = std::fs::read_to_string(
root.join(format!(".doctrine/slice/{padded}/slice-{padded}.toml")),
)
.unwrap();
let (kref, _id) = crate::integrity::parse_canonical_ref("SL-001").unwrap();
crate::relation::tier1_edges(kref.kind, &text).unwrap()
}
#[test]
fn link_references_role_round_trips_and_reads_back() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_sl_toml(root, 1);
seed_spec_dir(root, "SPEC-001");
run_link(
Some(root.to_path_buf()),
"SL-001",
"references",
Some("implements"),
None,
"SPEC-001",
)
.unwrap();
let content =
std::fs::read_to_string(root.join(".doctrine/slice/001/slice-001.toml")).unwrap();
assert!(content.contains("label = \"references\""), "label written");
assert!(
content.contains("role = \"implements\""),
"role cell written"
);
assert!(content.contains("target = \"SPEC-001\""), "target written");
let edges = sl_edges(root, 1);
assert_eq!(
crate::relation::targets_for_role(
&edges,
crate::relation::RelationLabel::References,
crate::relation::Role::Implements,
),
vec!["SPEC-001".to_string()],
);
assert_eq!(
crate::relation::inbound_name(
crate::relation::RelationLabel::References,
Some(crate::relation::Role::Implements),
),
"implemented by",
);
run_unlink(
Some(root.to_path_buf()),
"SL-001",
"references",
Some("implements"),
"SPEC-001",
)
.unwrap();
let after =
std::fs::read_to_string(root.join(".doctrine/slice/001/slice-001.toml")).unwrap();
assert!(
!after.contains("target = \"SPEC-001\""),
"the references(implements) edge is gone after unlink"
);
}
#[test]
fn unlink_matches_the_full_triple_only() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_sl_toml(root, 1);
seed_sl_toml(root, 2);
run_link(
Some(root.to_path_buf()),
"SL-001",
"references",
Some("concerns"),
None,
"SL-002",
)
.unwrap();
run_unlink(
Some(root.to_path_buf()),
"SL-001",
"references",
Some("originates_from"),
"SL-002",
)
.unwrap();
let still =
std::fs::read_to_string(root.join(".doctrine/slice/001/slice-001.toml")).unwrap();
assert!(
still.contains("role = \"concerns\""),
"the concerns edge survives an unlink of a different role"
);
run_unlink(
Some(root.to_path_buf()),
"SL-001",
"references",
Some("concerns"),
"SL-002",
)
.unwrap();
let after =
std::fs::read_to_string(root.join(".doctrine/slice/001/slice-001.toml")).unwrap();
assert!(
!after.contains("target = \"SL-002\""),
"the matching triple is removed"
);
}
#[test]
fn link_references_without_role_is_missing_role() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_sl_toml(root, 1);
seed_spec_dir(root, "SPEC-001");
let result = run_link(
Some(root.to_path_buf()),
"SL-001",
"references",
None,
None,
"SPEC-001",
);
let err = result.unwrap_err().to_string();
assert!(err.contains("requires a role"), "MissingRole: {err}");
let content =
std::fs::read_to_string(root.join(".doctrine/slice/001/slice-001.toml")).unwrap();
assert!(!content.contains("[[relation]]"), "no edge authored");
}
#[test]
fn link_label_only_with_role_is_not_applicable() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_sl_toml(root, 1);
seed_adr_toml(root, 1);
let result = run_link(
Some(root.to_path_buf()),
"SL-001",
"governed_by",
Some("implements"),
None,
"ADR-001",
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("does not take a role"),
"RoleNotApplicable: {err}"
);
let content =
std::fs::read_to_string(root.join(".doctrine/slice/001/slice-001.toml")).unwrap();
assert!(!content.contains("[[relation]]"), "no edge authored");
}
#[test]
fn link_illegal_role_for_source_is_refused() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_sl_toml(root, 1);
seed_sl_toml(root, 2);
run_link(
Some(root.to_path_buf()),
"SL-001",
"references",
Some("concerns"),
None,
"SL-002",
)
.unwrap();
let result = run_link(
Some(root.to_path_buf()),
"SL-001",
"references",
Some("implements"),
None,
"SL-002",
);
assert!(result.is_err(), "implements → a slice target is refused");
}
#[test]
fn link_unknown_role_spelling_is_refused() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_sl_toml(root, 1);
seed_spec_dir(root, "SPEC-001");
let result = run_link(
Some(root.to_path_buf()),
"SL-001",
"references",
Some("realises"),
None,
"SPEC-001",
);
let err = result.unwrap_err().to_string();
assert!(err.contains("is not a known role"), "unknown role: {err}");
}
}