mod common;
use std::collections::{BTreeMap, BTreeSet};
use common::{corpus_a, corpus_a_expected, dbmd};
fn graph_json(args: &[&str]) -> serde_json::Value {
let out = dbmd()
.arg("--json")
.arg("graph")
.args(args)
.arg("--dir")
.arg(corpus_a())
.assert()
.success();
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
serde_json::from_str(&stdout).expect("graph --json emits JSON")
}
fn summary_of(target: &str) -> Option<String> {
let text = std::fs::read_to_string(corpus_a().join(format!("{target}.md"))).ok()?;
let (fm, _) = common::split_frontmatter_body(&text)?;
let raw = fm
.lines()
.find_map(|l| l.trim().strip_prefix("summary:").map(str::trim))?;
let unquoted = raw
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| raw.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
.unwrap_or(raw);
Some(unquoted.to_string())
}
fn forwardlinks_of(file: &str) -> BTreeSet<String> {
graph_json(&["forwardlinks", file])
.as_array()
.expect("forwardlinks --json is an array")
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect()
}
#[derive(serde::Deserialize)]
struct BacklinksCase {
seed: String,
matches: Vec<String>,
}
#[derive(serde::Deserialize, Clone)]
struct NeighborNode {
path: String,
hops: u64,
direction: String,
via: String,
#[serde(rename = "type")]
ty: String,
}
#[derive(serde::Deserialize)]
struct NeighborhoodCase {
seed: String,
hops: u64,
seed_normalized: String,
nodes: Vec<NeighborNode>,
}
#[derive(serde::Deserialize)]
struct GraphGolden {
backlinks: Vec<BacklinksCase>,
neighborhood: Vec<NeighborhoodCase>,
}
fn graph_golden() -> GraphGolden {
let raw = std::fs::read_to_string(corpus_a_expected("graph.json"))
.expect("EXPECTED/graph.json is committed");
serde_json::from_str(&raw).expect("EXPECTED/graph.json is valid JSON")
}
#[test]
fn backlinks_golden_seeds_return_the_expected_incoming_edges() {
let golden = graph_golden();
assert!(
!golden.backlinks.is_empty(),
"the graph golden has backlinks cases"
);
for case in &golden.backlinks {
let json_arr: Vec<String> = graph_json(&["backlinks", &case.seed])
.as_array()
.expect("backlinks --json is an array")
.iter()
.map(|v| v.as_str().expect("each backlink is a string").to_string())
.collect();
assert_eq!(
json_arr, case.matches,
"backlinks --json for {} must equal the golden incoming-edge set",
case.seed
);
for m in &json_arr {
assert!(
!m.ends_with("/index") && m != "index" && !m.contains("index.jsonl"),
"backlinks for {} leaked a catalog/meta path `{m}` — indexes are not relationship edges",
case.seed
);
assert!(
!m.ends_with(".md"),
"backlinks must be bare wiki-link form (no .md); got `{m}` for {}",
case.seed
);
}
let out = dbmd()
.args(["graph", "backlinks", &case.seed])
.arg("--dir")
.arg(corpus_a())
.assert()
.success();
let text = String::from_utf8(out.get_output().stdout.clone()).unwrap();
let text_set: BTreeSet<String> = text
.lines()
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect();
let json_set: BTreeSet<String> = json_arr.iter().cloned().collect();
assert_eq!(
text_set, json_set,
"text-mode backlinks for {} must agree with --json on the file set",
case.seed
);
}
}
#[test]
fn backlinks_orphan_seed_is_empty_success() {
let golden = graph_golden();
let orphan = golden
.backlinks
.iter()
.find(|c| c.matches.is_empty())
.expect("the golden includes an orphan backlinks seed");
let out = dbmd()
.args(["graph", "backlinks", &orphan.seed])
.arg("--dir")
.arg(corpus_a())
.assert()
.success();
assert!(
out.get_output().stdout.is_empty(),
"an orphan seed's backlinks must print nothing"
);
}
#[test]
fn neighborhood_golden_cases_match_topology_and_hydrate_neighbor_summaries() {
let golden = graph_golden();
assert!(
!golden.neighborhood.is_empty(),
"the graph golden has neighborhood cases"
);
for case in &golden.neighborhood {
let v = graph_json(&["neighborhood", &case.seed, "--hops", &case.hops.to_string()]);
assert_eq!(
v["seed"], case.seed_normalized,
"neighborhood seed for {} must be the normalized bare path",
case.seed
);
let nodes = v["nodes"]
.as_array()
.expect("neighborhood has a nodes array");
let tool: BTreeMap<String, (u64, String, String, String)> = nodes
.iter()
.map(|n| {
(
n["path"].as_str().unwrap().to_string(),
(
n["hops"].as_u64().unwrap(),
n["direction"].as_str().unwrap().to_string(),
n["via"].as_str().unwrap().to_string(),
n["type"].as_str().unwrap().to_string(),
),
)
})
.collect();
let want: BTreeMap<String, (u64, String, String, String)> = case
.nodes
.iter()
.map(|n| {
(
n.path.clone(),
(n.hops, n.direction.clone(), n.via.clone(), n.ty.clone()),
)
})
.collect();
assert_eq!(
tool, want,
"neighborhood({}, hops={}) topology (path→hops/direction/via/type) must equal the golden",
case.seed, case.hops
);
let hop1: BTreeSet<&str> = case
.nodes
.iter()
.filter(|n| n.hops == 1)
.map(|n| n.path.as_str())
.collect();
for n in nodes {
let path = n["path"].as_str().unwrap();
let hops = n["hops"].as_u64().unwrap();
let via = n["via"].as_str().unwrap();
let direction = n["direction"].as_str().unwrap();
let got_summary = n["summary"].as_str().unwrap_or("");
assert!(
!got_summary.is_empty(),
"node {path} must carry a non-empty summary"
);
let file_summary = summary_of(path).unwrap_or_else(|| {
panic!("node {path} should resolve to a corpus file w/ summary")
});
assert_eq!(
got_summary, file_summary,
"node {path} must hydrate the NEIGHBOR's own summary, verbatim from its frontmatter"
);
assert_ne!(
got_summary,
summary_of(&case.seed_normalized).unwrap_or_default(),
"node {path} must not echo the SEED's summary"
);
if hops == 1 {
assert_eq!(
via, case.seed_normalized,
"a hop-1 node ({path}) must be reached via the seed"
);
} else {
assert!(
hop1.contains(via),
"a hop-{hops} node ({path}) must be reached via a hop-1 node, got via=`{via}`"
);
}
match direction {
"outgoing" => assert!(
forwardlinks_of(&format!("{via}.md")).contains(path),
"node {path} claims direction=outgoing via {via}, but {via} has no forwardlink to it"
),
"incoming" => assert!(
forwardlinks_of(&format!("{path}.md")).contains(via),
"node {path} claims direction=incoming via {via}, but it has no forwardlink to {via}"
),
other => panic!("node {path} has an unrecognized direction `{other}`"),
}
}
}
}
#[test]
fn neighborhood_orphan_seed_hydrates_to_empty() {
let golden = graph_golden();
let orphan = golden
.neighborhood
.iter()
.find(|c| c.nodes.is_empty())
.expect("the golden includes an orphan neighborhood case");
let v = graph_json(&[
"neighborhood",
&orphan.seed,
"--hops",
&orphan.hops.to_string(),
]);
assert_eq!(v["seed"], orphan.seed_normalized);
assert!(
v["nodes"].as_array().unwrap().is_empty(),
"an isolated seed hydrates to no nodes"
);
}
#[derive(serde::Deserialize, PartialEq, Debug)]
struct LogEntry {
timestamp: String,
kind: String,
object: Option<String>,
note: String,
}
#[derive(serde::Deserialize)]
struct LogTailGolden {
tail_n: u64,
expected_count: usize,
chronological_oldest_first: bool,
entries: Vec<LogEntry>,
}
fn log_tail_golden() -> LogTailGolden {
let raw = std::fs::read_to_string(corpus_a_expected("log-tail.json"))
.expect("EXPECTED/log-tail.json is committed");
serde_json::from_str(&raw).expect("EXPECTED/log-tail.json is valid JSON")
}
#[test]
fn log_tail_json_matches_the_normalized_golden() {
let golden = log_tail_golden();
assert!(
golden.chronological_oldest_first,
"golden documents oldest-first order"
);
let out = dbmd()
.args(["--json", "log", "tail", &golden.tail_n.to_string()])
.arg("--dir")
.arg(corpus_a())
.assert()
.success();
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
let raw: Vec<serde_json::Value> =
serde_json::from_str(&stdout).expect("log tail --json is an array");
assert_eq!(
raw.len(),
golden.expected_count,
"tail {} must return {} entries (the whole active log)",
golden.tail_n,
golden.expected_count
);
let tool: Vec<LogEntry> = raw
.iter()
.map(|e| LogEntry {
timestamp: e["timestamp"]
.as_str()
.expect("timestamp string")
.to_string(),
kind: e["kind"].as_str().expect("kind string").to_string(),
object: match &e["object"] {
serde_json::Value::Null => None,
serde_json::Value::String(s) => Some(s.clone()),
other => panic!("log entry `object` must be string or null, got {other}"),
},
note: e["note"].as_str().expect("note string").to_string(),
})
.collect();
assert_eq!(
tool, golden.entries,
"log tail --json must equal the normalized golden array, in chronological order"
);
let timestamps: Vec<&str> = tool.iter().map(|e| e.timestamp.as_str()).collect();
let mut sorted = timestamps.clone();
sorted.sort_unstable(); assert_eq!(
timestamps, sorted,
"tail must return entries oldest-first (chronological)"
);
let per_file = tool
.iter()
.find(|e| e.kind == "link")
.expect("the log has a `link` entry");
let obj = per_file.object.as_deref().unwrap_or("");
assert!(
obj.starts_with("[[") && obj.ends_with("]]"),
"a per-file log object must be the raw wiki-link form, got {obj:?}"
);
let rebuild = tool
.iter()
.find(|e| e.kind == "index-rebuild")
.expect("the log has an `index-rebuild` entry");
assert_eq!(
rebuild.object.as_deref(),
Some("-"),
"a store-wide `index-rebuild` records its object as the literal `-`"
);
let validate = tool
.iter()
.rev()
.find(|e| e.kind == "validate")
.expect("the log ends with a `validate` entry");
assert_eq!(
validate.object, None,
"a `validate` entry with no object slot must serialize `object` as null (not `-`)"
);
}
#[test]
fn log_tail_small_n_returns_newest_window_in_order() {
let golden = log_tail_golden();
let n = 3usize;
assert!(
golden.entries.len() > n,
"the golden has more than {n} entries"
);
let want: &[LogEntry] = &golden.entries[golden.entries.len() - n..];
let out = dbmd()
.args(["--json", "log", "tail", &n.to_string()])
.arg("--dir")
.arg(corpus_a())
.assert()
.success();
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
let raw: Vec<serde_json::Value> = serde_json::from_str(&stdout).unwrap();
assert_eq!(raw.len(), n, "tail {n} returns exactly {n} entries");
let tool: Vec<LogEntry> = raw
.iter()
.map(|e| LogEntry {
timestamp: e["timestamp"].as_str().unwrap().to_string(),
kind: e["kind"].as_str().unwrap().to_string(),
object: match &e["object"] {
serde_json::Value::Null => None,
serde_json::Value::String(s) => Some(s.clone()),
other => panic!("object must be string or null, got {other}"),
},
note: e["note"].as_str().unwrap().to_string(),
})
.collect();
assert_eq!(
tool, want,
"tail {n} must be the newest {n} golden entries, oldest-first"
);
}