use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use serde_json::Value;
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn run_kb(
db: &Path,
stdin: Option<&str>,
args: &[&str],
) -> Result<(std::process::ExitStatus, String, String), Box<dyn std::error::Error>> {
let bin = env!("CARGO_BIN_EXE_kb");
let home = db
.parent()
.ok_or("db path has no parent directory for HOME sandbox")?;
let mut cmd = Command::new(bin);
cmd.env("HOME", home)
.env("XDG_CONFIG_HOME", home)
.arg("--db")
.arg(db);
for a in args {
cmd.arg(a);
}
cmd.stdin(if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
});
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn()?;
if let Some(payload) = stdin {
child
.stdin
.as_mut()
.ok_or("kb child stdin missing")?
.write_all(payload.as_bytes())?;
}
let out = child.wait_with_output()?;
Ok((
out.status,
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
))
}
fn extract_data(stdout: &str) -> Result<Value, Box<dyn std::error::Error>> {
let v: Value = serde_json::from_str(stdout.trim())?;
v.get("data")
.cloned()
.ok_or_else(|| "envelope.data missing".into())
}
fn create_node(db: &Path, org: &str, id: &str) -> TestResult {
let (st, out, err) = run_kb(db, Some(org), &["create", "--id", id, "--json"])?;
assert!(st.success(), "create {id} failed: {err}\nstdout: {out}");
Ok(())
}
fn sole_outgoing(db: &Path, node: &str) -> Result<Value, Box<dyn std::error::Error>> {
let (st, out, err) = run_kb(db, None, &["links", node, "--json"])?;
assert!(st.success(), "links {node} failed: {err}");
let data = extract_data(&out)?;
let outgoing = data
.get("outgoing")
.and_then(Value::as_array)
.ok_or_else(|| format!("links {node}: outgoing array missing"))?;
assert_eq!(outgoing.len(), 1, "links {node}: expected one outgoing row");
let row = outgoing
.first()
.cloned()
.ok_or_else(|| format!("links {node}: outgoing empty"))?;
Ok(row)
}
#[test]
#[allow(
clippy::too_many_lines,
reason = "cohesive end-to-end CLI verification; splitting would fragment the fixture setup"
)]
fn unified_cli_verbs() -> TestResult {
let dir = tempfile::tempdir()?;
let db = dir.path().join("kb.db");
create_node(&db, "#+name: alpha\n* Target\n\nI am alpha.\n", "target")?;
create_node(&db, "* Source\n\nSee [[alpha]] for context.\n", "source")?;
create_node(
&db,
"* Id Source\n\nSee [[id:target]] for context.\n",
"id-src",
)?;
create_node(
&db,
"* Broken\n\nSee [[ghost]] which does not exist.\n",
"broken-ref",
)?;
create_node(&db, "* Lonely\n\nNo one links to me.\n", "lonely")?;
let (st, out, err) = run_kb(&db, None, &["links", "target", "--json"])?;
assert!(st.success(), "links target failed: {err}");
let data = extract_data(&out)?;
let incoming = data
.get("incoming")
.and_then(Value::as_array)
.ok_or("links target: incoming array missing")?;
let by_src: std::collections::HashMap<&str, &str> = incoming
.iter()
.filter_map(|r| {
let s = r.get("source_id").and_then(Value::as_str)?;
let t = r.get("link_type").and_then(Value::as_str)?;
Some((s, t))
})
.collect();
assert_eq!(by_src.get("source"), Some(&"name"));
assert_eq!(by_src.get("id-src"), Some(&"id"));
let row = sole_outgoing(&db, "source")?;
assert_eq!(row.get("link_type").and_then(Value::as_str), Some("name"));
assert_eq!(
row.get("target_slug").and_then(Value::as_str),
Some("alpha")
);
assert_eq!(row.get("target_id").and_then(Value::as_str), Some("target"));
let row = sole_outgoing(&db, "id-src")?;
assert_eq!(row.get("link_type").and_then(Value::as_str), Some("id"));
assert_eq!(row.get("target_id").and_then(Value::as_str), Some("target"));
assert!(
row.get("target_slug").is_some_and(Value::is_null),
"id-link rows must carry target_slug=null"
);
let row = sole_outgoing(&db, "broken-ref")?;
assert_eq!(row.get("link_type").and_then(Value::as_str), Some("name"));
assert!(
row.get("target_id").is_some_and(Value::is_null),
"broken name-link must carry target_id=null"
);
assert_eq!(
row.get("target_slug").and_then(Value::as_str),
Some("ghost")
);
let (st, out, err) = run_kb(&db, None, &["links", "target"])?;
assert!(st.success(), "links target (text) failed: {err}");
assert!(out.contains("[id]"), "human output must show [id]: {out}");
assert!(
out.contains("[name]"),
"human output must show [name]: {out}"
);
let (st, out, err) = run_kb(&db, None, &["orphans", "--json"])?;
assert!(st.success(), "orphans failed: {err}");
let data = extract_data(&out)?;
let ids: Vec<&str> = data
.as_array()
.ok_or("orphans: expected array")?
.iter()
.filter_map(|n| n.get("id").and_then(Value::as_str))
.collect();
assert!(!ids.contains(&"target"));
assert!(ids.contains(&"source"));
assert!(ids.contains(&"id-src"));
assert!(ids.contains(&"broken-ref"));
assert!(ids.contains(&"lonely"));
let (st, out, err) = run_kb(&db, None, &["hubs", "--json"])?;
assert!(st.success(), "hubs failed: {err}");
let data = extract_data(&out)?;
let arr = data.as_array().ok_or("hubs: expected array")?;
assert!(!arr.is_empty(), "expected at least one hub");
let top = arr.first().ok_or("hubs: empty ranking")?;
assert_eq!(top.get("id").and_then(Value::as_str), Some("target"));
assert_eq!(top.get("in_degree").and_then(Value::as_i64), Some(2));
let (st, out, err) = run_kb(&db, None, &["broken", "--json"])?;
assert!(st.success(), "broken failed: {err}");
let data = extract_data(&out)?;
let arr = data.as_array().ok_or("broken: expected array")?;
assert_eq!(arr.len(), 1);
let row = arr.first().ok_or("broken: empty list")?;
assert_eq!(row.get("link_type").and_then(Value::as_str), Some("name"));
assert_eq!(
row.get("target_slug").and_then(Value::as_str),
Some("ghost")
);
assert!(
row.get("target_id").is_some_and(Value::is_null),
"broken row must carry target_id=null"
);
Ok(())
}