use std::collections::BTreeSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::catalog::graph::CatalogGraph;
use crate::catalog::hydrate::CatalogKey;
use crate::catalog::scan::{EntityKey, ScanMode};
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum GraphFormat {
Dot,
Json,
}
impl std::fmt::Display for GraphFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Dot => f.write_str("dot"),
Self::Json => f.write_str("json"),
}
}
}
impl std::str::FromStr for GraphFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"dot" => Ok(Self::Dot),
"json" => Ok(Self::Json),
other => Err(format!(
"unknown format '{other}' (expected 'dot' or 'json')"
)),
}
}
}
pub(crate) fn build_graph_output(
root: &Path,
focus: Option<&str>,
depth: u32,
kinds: &[String],
label: Option<&str>,
include_memory: bool,
format: &GraphFormat,
) -> anyhow::Result<String> {
let g_full = CatalogGraph::from_catalog(&crate::catalog::hydrate::scan_catalog(
root,
ScanMode::default(),
)?);
let kind_set: BTreeSet<String> = {
let mut set = BTreeSet::new();
for k in kinds {
let upper = k.to_uppercase();
if !crate::kinds::ALL_KINDS.contains(&upper.as_str()) && upper != "MEM" {
let legal: Vec<&str> = crate::kinds::ALL_KINDS
.iter()
.copied()
.chain(std::iter::once("MEM"))
.collect();
anyhow::bail!(
"unknown kind prefix '{k}'; legal prefixes: {}",
legal.join(", ")
);
}
set.insert(upper);
}
set
};
let kind_given = !kind_set.is_empty();
let focus_owned = focus.map(std::borrow::ToOwned::to_owned);
let focus_key: Option<CatalogKey> = match focus_owned.as_deref() {
Some(f) => Some(resolve_focus(root, f)?),
None => None,
};
if let (Some(fk), Some(f)) = (&focus_key, focus) {
if !g_full.contains(fk) {
anyhow::bail!("focus '{f}' not found");
}
let mut nf = g_full.clone();
if kind_given {
nf = nf.filter_kinds(&kind_set);
}
if !include_memory {
nf = nf.exclude_memory();
}
if !nf.contains(fk) {
if matches!(fk, CatalogKey::Memory(_)) && !include_memory {
anyhow::bail!("MEM focus requires --include-memory");
}
let joined: Vec<String> = kind_set.iter().cloned().collect();
anyhow::bail!("{} excluded by --kind {}", f, joined.join(", "));
}
}
let mut g = g_full;
if kind_given {
g = g.filter_kinds(&kind_set);
}
if !include_memory {
g = g.exclude_memory();
}
if let Some(l) = &label {
g = g.filter_label(l);
}
if let Some(fk) = &focus_key {
g = g.neighbourhood(fk, depth);
} else if label.is_some() {
g = g.drop_isolated();
}
match format {
GraphFormat::Json => Ok(serde_json::to_string(&g)?),
GraphFormat::Dot => {
let dot = crate::catalog::dot::render(&g, focus_key.as_ref());
Ok(dot)
}
}
}
fn resolve_focus(root: &Path, f: &str) -> anyhow::Result<CatalogKey> {
if f.starts_with("mem.") || f.starts_with("mem_") {
let mref = crate::memory::MemoryRef::parse(f)?;
let all = crate::memory::collect_all(root)?;
let mem = crate::memory::resolve_memory_from_all(&all, &mref)?;
return Ok(CatalogKey::Memory(mem.uid.clone()));
}
let (kref, id) = crate::kinds::parse_canonical_ref(f)
.map_err(|e| anyhow::anyhow!("focus '{f}' not found: {e}"))?;
Ok(CatalogKey::Numbered(EntityKey {
prefix: kref.kind.prefix,
id,
}))
}
#[expect(
clippy::needless_pass_by_value,
reason = "clap dispatch signature — clap consumes the values"
)]
pub(crate) fn run_graph(
path: Option<PathBuf>,
focus: Option<String>,
depth: u32,
kinds: Vec<String>,
label: Option<String>,
include_memory: bool,
format: GraphFormat,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let output = build_graph_output(
&root,
focus.as_deref(),
depth,
kinds.as_slice(),
label.as_deref(),
include_memory,
&format,
)?;
writeln!(std::io::stdout(), "{output}")?;
Ok(())
}
#[cfg(test)]
#[expect(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
mod tests {
use super::*;
use crate::catalog::test_helpers::{seed_adr, seed_slice, seed_spec, tmp};
use crate::test_support::SCHEMA_MEMORY;
use std::fs;
fn seed_memory_item(root: &Path, uid: &str, title: &str, relations: &[(&str, &str)]) {
let mem_uid = format!("mem_{uid}");
let dir = root.join(".doctrine/memory/items").join(&mem_uid);
fs::create_dir_all(&dir).unwrap();
let rels: Vec<String> = relations
.iter()
.map(|(l, t)| format!("[[relation]]\nlabel = \"{l}\"\ntarget = \"{t}\"\n"))
.collect();
fs::write(
dir.join("memory.toml"),
format!(
"schema = \"{SCHEMA_MEMORY}\"\n\
schema_version = 1\n\
memory_uid = \"{mem_uid}\"\n\
title = \"{title}\"\n\
status = \"active\"\n\
memory_type = \"pattern\"\n\
created = \"2026-01-01\"\n\
updated = \"2026-01-01\"\n\
[scope]\n\
paths = []\n\
commands = []\n\
tags = []\n\
workspace = \"default\"\n\
repo = \"default\"\n\
[git]\n\
anchor_kind = \"none\"\n\
[review]\n\
verification_state = \"unverified\"\n\
review_by = \"2027-01-01\"\n\
[trust]\n\
trust_level = \"medium\"\n\
{}",
rels.concat()
),
)
.unwrap();
}
#[test]
fn bare_no_focus_output_starts_digraph() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[]);
let output =
build_graph_output(root, None, 1, &[], None, false, &GraphFormat::Dot).unwrap();
assert!(
output.starts_with("digraph G {"),
"expected DOT output; got: {output}"
);
}
#[test]
fn focus_depth_bounds_output() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[("references(implements)", &["SPEC-002"])]);
seed_spec(
root,
crate::spec::SpecSubtype::Tech,
2,
&[],
&[],
&[("descends_from", "PRD-003")],
);
seed_spec(root, crate::spec::SpecSubtype::Product, 3, &[], &[], &[]);
let d1 = build_graph_output(root, Some("SL-001"), 1, &[], None, false, &GraphFormat::Dot)
.unwrap();
assert!(d1.contains("SL-001"));
assert!(d1.contains("SPEC-002"));
assert!(
!d1.contains("PRD-003"),
"PRD-003 at distance 2; depth=1 excludes it"
);
let d2 = build_graph_output(root, Some("SL-001"), 2, &[], None, false, &GraphFormat::Dot)
.unwrap();
assert!(d2.contains("SL-001"));
assert!(d2.contains("SPEC-002"));
assert!(d2.contains("PRD-003"));
}
#[test]
fn format_json_parity_with_serde() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[("supersedes", &["ADR-002"])]);
seed_adr(root, 2, &[]);
let output = build_graph_output(
root,
Some("SL-001"),
1,
&[],
None,
false,
&GraphFormat::Json,
)
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert!(parsed.is_object());
let catalog = crate::catalog::hydrate::scan_catalog(root, ScanMode::default()).unwrap();
let g_full = CatalogGraph::from_catalog(&catalog);
let sl = CatalogKey::Numbered(EntityKey {
prefix: "SL",
id: 1,
});
let g = g_full.neighbourhood(&sl, 1);
let expected = serde_json::to_string(&g).unwrap();
assert_eq!(output, expected);
}
#[test]
fn empty_projection_yields_valid_empty_digraph() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[]);
let output = build_graph_output(
root,
Some("SL-001"),
1,
&["ADR".to_string()],
None,
false,
&GraphFormat::Dot,
);
assert!(output.is_err());
let output = build_graph_output(
root,
None,
1,
&["ADR".to_string()],
None,
false,
&GraphFormat::Dot,
)
.unwrap();
assert!(output.contains("digraph G {"));
assert!(output.contains('}'));
let json_out = build_graph_output(
root,
None,
1,
&["ADR".to_string()],
None,
false,
&GraphFormat::Json,
)
.unwrap();
let _: serde_json::Value = serde_json::from_str(&json_out).unwrap();
}
#[test]
fn mem_focus_with_include_memory_resolves() {
let dir = tmp();
let root = dir.path();
let uid = "aaaaaaaa00000000bbbbbbbb11111111";
seed_memory_item(root, uid, "TestMem", &[]);
let output = build_graph_output(
root,
Some(&format!("mem_{uid}")),
1,
&[],
None,
true,
&GraphFormat::Dot,
)
.unwrap();
assert!(output.contains(&format!("mem_{uid}")));
}
#[test]
fn mem_focus_without_include_memory_errors() {
let dir = tmp();
let root = dir.path();
let uid = "aaaaaaaa00000000bbbbbbbb11111111";
seed_memory_item(root, uid, "TestMem", &[]);
let err = build_graph_output(
root,
Some(&format!("mem_{uid}")),
1,
&[],
None,
false,
&GraphFormat::Dot,
)
.unwrap_err();
assert!(
err.to_string().contains("--include-memory"),
"error should mention --include-memory: {err}"
);
}
#[test]
fn focus_excluded_by_kind_errors() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[]);
let err = build_graph_output(
root,
Some("SL-001"),
1,
&["ADR".to_string()],
None,
false,
&GraphFormat::Dot,
)
.unwrap_err();
assert!(
err.to_string().contains("excluded by --kind"),
"error should mention excluded by --kind: {err}"
);
assert!(
err.to_string().contains("SL-001"),
"error should name the excluded focus: {err}"
);
}
#[test]
fn unknown_focus_errors() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[]);
let err = build_graph_output(root, Some("SL-999"), 1, &[], None, false, &GraphFormat::Dot)
.unwrap_err();
assert!(
err.to_string().contains("not found"),
"unknown focus should error: {err}"
);
}
#[test]
fn unknown_kind_validated_against_all_kinds_errors() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[]);
let err = build_graph_output(
root,
None,
1,
&["ZZZ".to_string()],
None,
false,
&GraphFormat::Dot,
)
.unwrap_err();
assert!(
err.to_string().contains("unknown kind prefix"),
"unknown kind should error: {err}"
);
assert!(
err.to_string().contains("legal prefixes"),
"error should list legal prefixes: {err}"
);
}
#[test]
fn unknown_format_errors() {
let result = "bad".parse::<GraphFormat>();
assert!(result.is_err());
}
}