mod common;
use common::{binary_path, run_cli, run_cli_stdin, Fixture};
use std::path::{Path, PathBuf};
fn stdout_of(out: &std::process::Output) -> String {
String::from_utf8_lossy(&out.stdout).to_string()
}
fn stderr_of(out: &std::process::Output) -> String {
String::from_utf8_lossy(&out.stderr).to_string()
}
fn plain(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
for c2 in chars.by_ref() {
if c2 == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[test]
fn e2e_cli_trust_builds_index() {
let fx = Fixture::new();
fx.trust();
assert!(fx.path().join(".reliary/index.sqlite").exists());
let conn = rusqlite::Connection::open(fx.path().join(".reliary/index.sqlite")).unwrap();
let files: i64 = conn
.query_row("SELECT COUNT(*) FROM file_map", [], |r| r.get(0))
.unwrap();
assert!(files >= 2, "expected both fixture files indexed, got {}", files);
}
#[test]
fn e2e_cli_trust_is_idempotent() {
let fx = Fixture::new();
let counts = |root: &Path| -> Vec<(String, i64)> {
let conn = rusqlite::Connection::open(root.join(".reliary/index.sqlite")).unwrap();
["file_map", "phrases", "phrase_occ", "file_phrases", "file_stats"]
.iter()
.map(|t| {
let n: i64 = conn
.query_row(&format!("SELECT COUNT(*) FROM {}", t), [], |r| r.get(0))
.unwrap_or(-1);
(t.to_string(), n)
})
.collect()
};
fx.trust();
let first = counts(fx.path());
assert!(
first.iter().all(|(_, n)| *n > 0),
"first trust must populate every table: {:?}",
first
);
fx.trust();
let second = counts(fx.path());
assert_eq!(
first, second,
"re-trust must not duplicate rows — index size drifted: {:?} -> {:?}",
first, second
);
}
#[test]
fn e2e_cli_search_finds_symbol_file() {
let fx = Fixture::new();
fx.trust();
let out = run_cli(&["search", "alpha"], fx.path(), &[]);
assert!(out.status.success(), "search failed: {}", stderr_of(&out));
let text = plain(&stdout_of(&out));
assert!(
text.contains("lib.rs"),
"search for alpha must name src/lib.rs: {}",
text
);
}
#[test]
fn e2e_cli_search_without_index_explains_itself() {
let dir = tempfile::tempdir().unwrap();
let out = run_cli_stdin(&["search", "anything"], dir.path(), &[], "n\n");
let combined = plain(&format!("{}{}", stdout_of(&out), stderr_of(&out)));
assert!(
!combined.contains("panicked"),
"search without index must not panic: {}",
combined
);
let lower = combined.to_ascii_lowercase();
assert!(
lower.contains("no project index")
|| lower.contains("no index")
|| lower.contains("index")
|| lower.contains("trust"),
"search without index must explain how to fix it: {}",
combined
);
assert!(
!dir.path().join(".reliary/index.sqlite").exists(),
"declining the build prompt must leave the directory unindexed"
);
}
#[test]
fn e2e_cli_verify_true_claim() {
let fx = Fixture::new();
fx.trust();
let out = run_cli(&["verify", "alpha at src/lib.rs:3"], fx.path(), &[]);
let text = plain(&format!("{}{}", stdout_of(&out), stderr_of(&out)));
assert!(
text.contains("VERIFIED"),
"a true claim must verify: {}",
text
);
}
#[test]
fn e2e_cli_verify_false_claim_exits_nonzero() {
let fx = Fixture::new();
fx.trust();
let out = run_cli(&["verify", "zeta_missing at src/lib.rs:99000"], fx.path(), &[]);
assert!(
!out.status.success(),
"a falsified claim must exit non-zero: {}",
plain(&stdout_of(&out))
);
let text = plain(&format!("{}{}", stdout_of(&out), stderr_of(&out)));
assert!(
text.contains("FALSE") || text.contains("false"),
"falsified claim should be reported: {}",
text
);
}
#[test]
fn e2e_cli_verify_json_is_valid_and_stable() {
let fx = Fixture::new();
fx.trust();
let out = run_cli(&["verify", "--json", "alpha at src/lib.rs:3"], fx.path(), &[]);
let text = stdout_of(&out);
let parsed: serde_json::Value =
serde_json::from_str(text.trim()).unwrap_or_else(|e| panic!("verify --json invalid: {} ({})", e, text));
assert!(parsed["verified"].is_number(), "json needs a verified count: {}", parsed);
assert!(parsed["falsified"].is_number(), "json needs a falsified count: {}", parsed);
assert!(parsed["claims"].is_array(), "json needs a claims array: {}", parsed);
let out2 = run_cli(&["verify", "--json", "alpha at src/lib.rs:3"], fx.path(), &[]);
assert_eq!(
text, stdout_of(&out2),
"verify --json must be deterministic"
);
}
#[test]
fn e2e_cli_status_reports_index() {
let fx = Fixture::new();
fx.trust();
let out = run_cli(&["status"], fx.path(), &[]);
assert!(out.status.success(), "status failed: {}", stderr_of(&out));
let text = plain(&stdout_of(&out));
assert!(
text.contains("Index"),
"status must report the index: {}",
text
);
assert!(
text.contains('2'),
"status should report the 2 indexed files: {}",
text
);
}
#[test]
fn e2e_cli_doctor_runs_clean() {
let fx = Fixture::new();
fx.trust();
let out = run_cli(&["doctor"], fx.path(), &[]);
let text = plain(&format!("{}{}", stdout_of(&out), stderr_of(&out)));
assert!(!text.contains("panicked"), "doctor must not panic: {}", text);
assert!(
text.contains("binary") || text.contains("index"),
"doctor should report checks: {}",
text
);
}
#[test]
fn e2e_cli_doctor_without_index_still_runs() {
let dir = tempfile::tempdir().unwrap();
let out = run_cli(&["doctor"], dir.path(), &[]);
let text = plain(&format!("{}{}", stdout_of(&out), stderr_of(&out)));
assert!(!text.contains("panicked"), "doctor must not panic: {}", text);
}
#[test]
fn e2e_cli_wrap_compresses_repetitive_output() {
let dir = tempfile::tempdir().unwrap();
let script = "for i in $(seq 1 40); do echo \"Compiling crate_$i v0.1.0\"; done; echo 'error: boom'";
let out = run_cli(&["wrap", "bash", "-c", script], dir.path(), &[]);
let text = plain(&stdout_of(&out));
assert!(
text.contains("error: boom"),
"compression must preserve errors: {}",
text
);
let compiling_lines = text.lines().filter(|l| l.contains("Compiling")).count();
assert!(
compiling_lines < 40,
"expected the 40 Compiling lines to collapse, got {}: {}",
compiling_lines,
text
);
}
#[test]
fn e2e_cli_wrap_passes_through_source_reads() {
let fx = Fixture::new();
let src = fx.path().join("src/lib.rs");
let out = run_cli(
&["wrap", "cat", src.to_str().unwrap()],
fx.path(),
&[],
);
let text = stdout_of(&out);
assert!(
text.contains("pub fn alpha"),
"cat of a source file must pass through unchanged: {}",
text
);
assert!(
text.contains("pub fn beta") || text.contains("beta()"),
"full file body must be present: {}",
text
);
}
#[test]
fn e2e_cli_wrap_is_deterministic() {
let dir = tempfile::tempdir().unwrap();
let script = "for i in $(seq 1 25); do echo \"step $i\"; done";
let a = run_cli(&["wrap", "bash", "-c", script], dir.path(), &[]);
let b = run_cli(&["wrap", "bash", "-c", script], dir.path(), &[]);
assert_eq!(
stdout_of(&a),
stdout_of(&b),
"wrap output must be byte-identical across runs (KV-cache safety)"
);
}
fn fake_home() -> tempfile::TempDir {
let home = tempfile::tempdir().unwrap();
std::fs::write(home.path().join(".claude.json"), r#"{"mcpServers":{}}"#).unwrap();
std::fs::create_dir_all(home.path().join(".claude")).unwrap();
let oc = home.path().join(".config/opencode");
std::fs::create_dir_all(&oc).unwrap();
std::fs::write(oc.join("opencode.json"), r#"{"mcp":{}}"#).unwrap();
home
}
fn init_with(home: &Path, answers: &str) -> std::process::Output {
init_args(home, &["init"], answers)
}
fn init_args(home: &Path, args: &[&str], answers: &str) -> std::process::Output {
use std::io::Write;
use std::process::{Command, Stdio};
let mut child = Command::new(binary_path())
.args(args)
.current_dir(home)
.env("HOME", home)
.env("NO_RELIARY_WATCHER", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn init");
child
.stdin
.as_mut()
.unwrap()
.write_all(answers.as_bytes())
.unwrap();
child.wait_with_output().expect("init wait")
}
#[test]
fn e2e_cli_init_wires_mcp_entries() {
let home = fake_home();
let out = init_with(home.path(), "Y\nN\nY\nN\n");
let text = plain(&format!("{}{}", stdout_of(&out), stderr_of(&out)));
let claude: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(home.path().join(".claude.json")).unwrap())
.expect("claude config must stay valid JSON");
assert!(
claude["mcpServers"]["reliary"].is_object(),
"init must add the reliary MCP server to Claude config: {}",
claude
);
assert_eq!(
claude["mcpServers"]["reliary"]["args"][0], "mcp",
"MCP command must be the `mcp` subcommand: {}",
claude
);
let oc: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(home.path().join(".config/opencode/opencode.json")).unwrap(),
)
.expect("opencode config must stay valid JSON");
assert!(
oc["mcp"]["reliary"].is_object() || oc["mcpServers"]["reliary"].is_object(),
"init must add the reliary MCP server to OpenCode config: {}",
oc
);
assert!(!text.contains("panicked"), "init must not panic: {}", text);
}
#[test]
fn e2e_cli_init_is_idempotent() {
let home = fake_home();
init_with(home.path(), "Y\nN\nY\nN\n");
init_with(home.path(), "Y\nN\nY\nN\n");
let claude: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(home.path().join(".claude.json")).unwrap())
.unwrap();
let servers = claude["mcpServers"].as_object().expect("mcpServers object");
assert_eq!(
servers.keys().filter(|k| *k == "reliary").count(),
1,
"second init duplicated the server: {}",
claude
);
let args = claude["mcpServers"]["reliary"]["args"].as_array().unwrap();
assert_eq!(args.len(), 1, "args must not accumulate: {:?}", args);
}
#[test]
fn e2e_cli_uninstall_removes_mcp_entries() {
let home = fake_home();
init_with(home.path(), "Y\nN\nY\nN\n");
let before: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(home.path().join(".claude.json")).unwrap())
.unwrap();
assert!(
before["mcpServers"]["reliary"].is_object(),
"precondition: reliary must be wired"
);
let out = run_cli(&["uninstall"], home.path(), &[("HOME", home.path().to_str().unwrap())]);
let text = plain(&format!("{}{}", stdout_of(&out), stderr_of(&out)));
let after: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(home.path().join(".claude.json")).unwrap())
.expect("claude config must remain valid JSON after uninstall");
assert!(
after["mcpServers"].get("reliary").is_none(),
"uninstall must remove the reliary MCP server: {}",
after
);
assert!(!text.contains("panicked"), "uninstall must not panic: {}", text);
}
fn rel_shim(home: &Path) -> PathBuf {
home.join(".local/bin").join(if cfg!(windows) { "rel.cmd" } else { "rel" })
}
#[test]
fn e2e_cli_init_installs_rel_alias() {
let home = fake_home();
let out = init_with(home.path(), "Y\nY\nY\nY\nY\n");
let text = plain(&format!("{}{}", stdout_of(&out), stderr_of(&out)));
let shim = rel_shim(home.path());
assert!(
shim.symlink_metadata().is_ok(),
"init must create the 'rel' shim it advertises: {}",
text
);
let rel_out = std::process::Command::new(&shim)
.arg("--version")
.current_dir(home.path())
.env("HOME", home.path())
.output()
.expect("run rel shim");
let rel_text = format!(
"{}{}",
String::from_utf8_lossy(&rel_out.stdout),
String::from_utf8_lossy(&rel_out.stderr)
);
assert!(
rel_text.contains("reliary") && rel_text.contains(env!("CARGO_PKG_VERSION")),
"'rel' shim must dispatch to reliary and report its version: {}",
rel_text
);
}
#[test]
fn e2e_cli_uninstall_removes_rel_alias() {
let home = fake_home();
init_with(home.path(), "Y\nY\nY\nY\nY\n");
assert!(
rel_shim(home.path()).symlink_metadata().is_ok(),
"precondition: init must have installed the shim"
);
let out = run_cli(
&["uninstall"],
home.path(),
&[("HOME", home.path().to_str().unwrap())],
);
assert!(
rel_shim(home.path()).symlink_metadata().is_err(),
"uninstall must remove the 'rel' shim: {}",
plain(&format!("{}{}", stdout_of(&out), stderr_of(&out)))
);
}
#[test]
fn e2e_cli_dry_run_does_not_install_rel_alias() {
let home = fake_home();
init_args(home.path(), &["init", "--dry-run"], "\n\n\n\n\n\n");
assert!(
rel_shim(home.path()).symlink_metadata().is_err(),
"dry-run must not create the 'rel' shim"
);
}
#[test]
fn e2e_cli_help_examples_use_the_real_binary_name() {
let dir = tempfile::tempdir().unwrap();
let out = run_cli(&["--help"], dir.path(), &[]);
let text = plain(&stdout_of(&out));
let examples: Vec<&str> = text
.lines()
.skip_while(|l| !l.starts_with("EXAMPLES"))
.skip(1)
.take_while(|l| !l.trim().is_empty())
.collect();
assert!(!examples.is_empty(), "help must list examples: {}", text);
for line in &examples {
let cmd = line.trim();
assert!(
cmd.starts_with("reliary "),
"example must invoke the real binary 'reliary': {:?}",
cmd
);
assert!(
!cmd.contains("reliary-agent"),
"example must not reference the crate name 'reliary-agent': {:?}",
cmd
);
}
assert!(
text.contains("Usage: reliary"),
"--help usage line must name the binary 'reliary': {}",
text
);
}
#[test]
fn e2e_cli_completions_and_man_emit_output() {
let dir = tempfile::tempdir().unwrap();
for shell in ["bash", "zsh", "fish"] {
let out = run_cli(&["completions", shell], dir.path(), &[]);
assert!(
out.status.success(),
"completions {} failed: {}",
shell,
stderr_of(&out)
);
assert!(
!stdout_of(&out).trim().is_empty(),
"completions {} produced no output",
shell
);
}
let out = run_cli(&["man"], dir.path(), &[]);
let text = format!("{}{}", stdout_of(&out), stderr_of(&out));
assert!(
text.contains("reliary"),
"man page must mention the binary: {}",
&text[..text.len().min(200)]
);
}
#[test]
fn e2e_cli_version_matches_crate_version() {
let dir = tempfile::tempdir().unwrap();
let out = run_cli(&["--version"], dir.path(), &[]);
let text = stdout_of(&out);
let expected = env!("CARGO_PKG_VERSION");
assert!(
text.contains(expected),
"--version must report the crate version {}: {}",
expected,
text
);
}
fn read_settings(home: &Path) -> serde_json::Value {
let raw = std::fs::read_to_string(home.join(".claude/settings.json"))
.expect("settings.json must exist");
serde_json::from_str(&raw).expect("settings.json must stay valid JSON")
}
fn hook_commands(settings: &serde_json::Value) -> Vec<(String, String)> {
let mut out = Vec::new();
if let Some(hooks) = settings.get("hooks").and_then(|h| h.as_object()) {
for (event, entries) in hooks {
let Some(arr) = entries.as_array() else { continue };
for entry in arr {
let Some(hs) = entry.get("hooks").and_then(|h| h.as_array()) else { continue };
for h in hs {
if let Some(cmd) = h.get("command").and_then(|c| c.as_str()) {
out.push((event.clone(), cmd.to_string()));
}
}
}
}
}
out
}
#[test]
fn e2e_cli_init_registers_resolvable_hooks() {
let home = fake_home();
let out = init_with(home.path(), "Y\nY\nY\nN\n");
let text = plain(&format!("{}{}", stdout_of(&out), stderr_of(&out)));
assert!(!text.contains("panicked"), "init must not panic: {}", text);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for name in ["reliary-session-reminder", "reliary-sift-pretooluse"] {
let p = home.path().join(".claude/hooks").join(name);
assert!(p.exists(), "hook file must be written: {}", p.display());
let mode = std::fs::metadata(&p).unwrap().permissions().mode();
assert_eq!(mode & 0o111, 0o111, "{} must be executable", p.display());
}
}
let settings = read_settings(home.path());
let commands = hook_commands(&settings);
assert!(
!commands.is_empty(),
"init must register at least one hook: {}",
settings
);
for (event, cmd) in &commands {
let prog = cmd.split_whitespace().next().unwrap_or("");
let path = if let Some(rest) = prog.strip_prefix("~/") {
home.path().join(rest)
} else {
std::path::PathBuf::from(prog)
};
assert!(
path.exists(),
"registered {} hook command does not exist on disk: {} (resolved {})",
event,
cmd,
path.display()
);
}
}
#[test]
fn e2e_cli_doctor_reports_wired_integrations() {
let home = fake_home();
init_with(home.path(), "Y\nY\nY\nN\n");
let out = run_cli(
&["doctor", "--format", "json"],
home.path(),
&[("HOME", home.path().to_str().unwrap())],
);
let text = stdout_of(&out);
let v: serde_json::Value = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("doctor --format json must emit JSON ({}): {}", e, text));
let checks = v["checks"].as_array().expect("checks array");
for name in ["claude", "opencode"] {
let c = checks
.iter()
.find(|c| c["name"] == name)
.unwrap_or_else(|| panic!("doctor must report a {} check: {}", name, v));
assert_eq!(
c["ok"], true,
"doctor must report {} wired after init: {}",
name, c
);
}
}
#[test]
fn e2e_cli_opencode_mcp_entry_matches_opencode_schema() {
let home = fake_home();
init_with(home.path(), "Y\nY\nY\nY\nY\n");
let oc: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(home.path().join(".config/opencode/opencode.json")).unwrap(),
)
.expect("opencode config must stay valid JSON");
let entry = &oc["mcp"]["reliary"];
assert_eq!(
entry["type"], "local",
"mcp.reliary must be type:\"local\" for OpenCode: {}",
entry
);
let cmd = entry["command"]
.as_array()
.unwrap_or_else(|| panic!("mcp.reliary.command must be an array: {}", entry));
assert!(!cmd.is_empty(), "command array must not be empty");
assert_eq!(cmd[1], "mcp", "second element must be the `mcp` subcommand: {}", entry);
assert!(
entry.get("args").is_none(),
"OpenCode entries must not carry a Claude-style \"args\" key: {}",
entry
);
}
#[test]
fn e2e_cli_opencode_plugin_entry_points_at_real_file() {
let home = fake_home();
init_with(home.path(), "Y\nY\nY\nY\nY\n");
let oc: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(home.path().join(".config/opencode/opencode.json")).unwrap(),
)
.unwrap();
let plugins = oc["plugin"].as_array().expect("plugin array must exist");
let reliary_plugin = plugins
.iter()
.filter_map(|p| p.as_str())
.find(|p| p.ends_with("opencode-plugin.js"))
.unwrap_or_else(|| panic!("plugin array must name the reliary plugin: {}", oc));
assert!(
std::path::Path::new(reliary_plugin).exists(),
"registered plugin file must exist on disk: {}",
reliary_plugin
);
assert!(
reliary_plugin.starts_with(home.path().to_str().unwrap()),
"plugin must be written into the data dir, not the source tree: {}",
reliary_plugin
);
}
#[test]
fn e2e_cli_uninstall_removes_opencode_plugin_and_file() {
let home = fake_home();
init_with(home.path(), "Y\nY\nY\nY\nY\n");
let oc_path = home.path().join(".config/opencode/opencode.json");
let plugin_path = serde_json::from_str::<serde_json::Value>(&std::fs::read_to_string(&oc_path).unwrap())
.unwrap()["plugin"]
.as_array()
.unwrap()
.iter()
.filter_map(|p| p.as_str())
.find(|p| p.ends_with("opencode-plugin.js"))
.expect("precondition: plugin must be registered")
.to_string();
run_cli(&["uninstall"], home.path(), &[("HOME", home.path().to_str().unwrap())]);
let after: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&oc_path).unwrap()).unwrap();
let still = after["plugin"]
.as_array()
.map(|a| a.iter().filter_map(|p| p.as_str()).any(|p| p.contains("opencode-plugin.js")))
.unwrap_or(false);
assert!(!still, "uninstall must remove the reliary plugin entry: {}", after);
assert!(
!std::path::Path::new(&plugin_path).exists(),
"uninstall must delete the materialized plugin file: {}",
plugin_path
);
}
#[test]
fn e2e_cli_opencode_jsonc_only_is_wired() {
let home = tempfile::tempdir().unwrap();
std::fs::write(home.path().join(".claude.json"), r#"{"mcpServers":{}}"#).unwrap();
std::fs::create_dir_all(home.path().join(".claude")).unwrap();
let oc = home.path().join(".config/opencode");
std::fs::create_dir_all(&oc).unwrap();
std::fs::write(oc.join("opencode.jsonc"), "{\n // opencode config\n \"mcp\": {}\n}\n").unwrap();
let out = init_with(home.path(), "N\nN\nY\nY\nY\n");
let text = plain(&format!("{}{}", stdout_of(&out), stderr_of(&out)));
assert!(
text.contains("Updated opencode.jsonc"),
"init must edit the .jsonc config it found: {}",
text
);
let raw = std::fs::read_to_string(oc.join("opencode.jsonc")).unwrap();
assert!(
raw.contains("reliary"),
"the .jsonc config must contain the reliary MCP entry: {}",
raw
);
}
#[test]
fn e2e_cli_doctor_flags_malformed_opencode_entry() {
let home = tempfile::tempdir().unwrap();
let oc = home.path().join(".config/opencode");
std::fs::create_dir_all(&oc).unwrap();
std::fs::write(
oc.join("opencode.json"),
r#"{"mcp":{"reliary":{"command":"/usr/bin/reliary","args":["mcp"]}}}"#,
)
.unwrap();
let out = run_cli(
&["doctor", "--format", "json"],
home.path(),
&[("HOME", home.path().to_str().unwrap())],
);
let v: serde_json::Value = serde_json::from_str(&stdout_of(&out)).unwrap();
let c = v["checks"]
.as_array()
.unwrap()
.iter()
.find(|c| c["name"] == "opencode")
.unwrap();
assert_eq!(
c["ok"], false,
"doctor must flag a malformed OpenCode MCP entry, not report it green: {}",
c
);
}
#[test]
fn e2e_cli_init_dry_run_mutates_nothing() { let home = fake_home();
let settings_path = home.path().join(".claude/settings.json");
std::fs::write(&settings_path, r#"{"env":{"KEEP":"1"}}"#).unwrap();
let before_settings = std::fs::read(&settings_path).unwrap();
let before_claude = std::fs::read(home.path().join(".claude.json")).unwrap();
let out = init_args(home.path(), &["init", "--dry-run"], "\n\n\n\n\n\n");
assert!(out.status.success(), "dry-run init must exit 0");
assert_eq!(
std::fs::read(&settings_path).unwrap(),
before_settings,
"dry-run must not modify settings.json"
);
assert_eq!(
std::fs::read(home.path().join(".claude.json")).unwrap(),
before_claude,
"dry-run must not modify .claude.json"
);
assert!(
!home.path().join(".claude/hooks").exists(),
"dry-run must not create hook files"
);
}
#[test]
fn e2e_cli_reinit_does_not_duplicate_hooks() {
let home = fake_home();
init_with(home.path(), "Y\nY\nY\nN\n");
let first = hook_commands(&read_settings(home.path()));
init_with(home.path(), "Y\nY\nY\nN\n");
let second = hook_commands(&read_settings(home.path()));
assert_eq!(
first.len(),
second.len(),
"a second init must not duplicate hook entries: {:?} -> {:?}",
first,
second
);
}
#[test]
fn e2e_cli_uninstall_removes_hooks_and_registrations() {
let home = fake_home();
let settings_path = home.path().join(".claude/settings.json");
std::fs::write(
&settings_path,
r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"/bin/true"}]}]}}"#,
)
.unwrap();
init_with(home.path(), "Y\nY\nY\nN\n");
assert!(
!hook_commands(&read_settings(home.path())).is_empty(),
"precondition: init must register hooks"
);
let out = run_cli(
&["uninstall"],
home.path(),
&[("HOME", home.path().to_str().unwrap())],
);
let text = plain(&format!("{}{}", stdout_of(&out), stderr_of(&out)));
assert!(!text.contains("panicked"), "uninstall must not panic: {}", text);
let remaining = hook_commands(&read_settings(home.path()));
let reliary: Vec<_> = remaining
.iter()
.filter(|(_, c)| c.contains("reliary"))
.collect();
assert!(
reliary.is_empty(),
"uninstall must strip every reliary hook registration: {:?}",
reliary
);
assert!(
remaining.iter().any(|(_, c)| c == "/bin/true"),
"uninstall must not touch unrelated hooks: {:?}",
remaining
);
assert!(
!home.path().join(".claude/hooks/reliary-session-reminder").exists(),
"uninstall must remove the reminder hook file"
);
assert!(
!home.path().join(".claude/hooks/reliary-sift-pretooluse").exists(),
"uninstall must remove the sift hook file"
);
}
#[test]
fn e2e_cli_doctor_flags_unusable_index() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".reliary")).unwrap();
std::fs::write(dir.path().join(".reliary/index.sqlite"), b"not a database").unwrap();
let out = run_cli(&["doctor", "--format", "json"], dir.path(), &[]);
let v: serde_json::Value = serde_json::from_str(&stdout_of(&out)).unwrap();
let index = v["checks"].as_array().unwrap().iter()
.find(|c| c["name"] == "index").expect("index check");
assert_eq!(
index["ok"], false,
"doctor must not report an unusable index as ok: {}", index
);
assert!(
index["detail"].as_str().unwrap_or("").contains("not a usable index"),
"doctor must explain the index is unusable: {}", index
);
assert_eq!(v["ready"], false, "an unusable index means not ready: {}", v);
let dir2 = tempfile::tempdir().unwrap();
let out = run_cli(&["doctor", "--format", "json"], dir2.path(), &[]);
let v: serde_json::Value = serde_json::from_str(&stdout_of(&out)).unwrap();
let index = v["checks"].as_array().unwrap().iter()
.find(|c| c["name"] == "index").expect("index check");
assert_eq!(index["ok"], false, "no index must not be ok: {}", index);
assert!(
index["detail"].as_str().unwrap_or("").contains("reliary trust"),
"doctor must suggest how to build the index: {}", index
);
}
#[test]
fn e2e_cli_doctor_flags_dangling_hook_registration() {
let home = fake_home();
std::fs::write(home.path().join(".claude.json"),
r#"{"mcpServers":{"reliary":{"command":"/bin/true","args":["mcp"]}}}"#).unwrap();
std::fs::create_dir_all(home.path().join(".claude/hooks")).unwrap();
std::fs::write(home.path().join(".claude/settings.json"),
r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"~/.claude/hooks/reliary-sift-pretooluse"}]}]}}"#).unwrap();
let out = run_cli(
&["doctor", "--format", "json"],
home.path(),
&[("HOME", home.path().to_str().unwrap())],
);
let v: serde_json::Value = serde_json::from_str(&stdout_of(&out)).unwrap();
let claude = v["checks"].as_array().unwrap().iter()
.find(|c| c["name"] == "claude").expect("claude check");
assert_eq!(
claude["ok"], false,
"doctor must flag a registered hook with no file on disk: {}", claude
);
assert!(
claude["detail"].as_str().unwrap_or("").contains("reliary-sift-pretooluse"),
"doctor must name the dangling hook: {}", claude
);
std::fs::write(home.path().join(".claude/hooks/reliary-sift-pretooluse"), "#!/bin/sh\n").unwrap();
std::fs::write(home.path().join(".claude/hooks/reliary-session-reminder"), "#!/bin/sh\n").unwrap();
let out = run_cli(
&["doctor", "--format", "json"],
home.path(),
&[("HOME", home.path().to_str().unwrap())],
);
let v: serde_json::Value = serde_json::from_str(&stdout_of(&out)).unwrap();
let claude = v["checks"].as_array().unwrap().iter()
.find(|c| c["name"] == "claude").expect("claude check");
assert_eq!(
claude["ok"], true,
"doctor must clear the flag once the hook file exists: {}", claude
);
}
#[test]
fn trust_self_ignores_reliary_in_git() {
let fx = Fixture::new();
fx.trust();
let gi = fx.path().join(".reliary/.gitignore");
assert!(gi.exists(), "trust must write .reliary/.gitignore");
assert_eq!(std::fs::read_to_string(&gi).unwrap().trim(), "*");
let status = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(fx.path())
.output()
.expect("git status");
let status = String::from_utf8_lossy(&status.stdout);
assert!(
!status.contains(".reliary"),
"git status must not mention .reliary, got: {status}"
);
let _ = std::process::Command::new("git")
.args(["add", "."]).current_dir(fx.path()).output();
let staged = std::process::Command::new("git")
.args(["diff", "--cached", "--name-only"])
.current_dir(fx.path())
.output()
.expect("git diff");
let staged = String::from_utf8_lossy(&staged.stdout);
assert!(
!staged.contains(".reliary"),
".reliary must never be staged, got: {staged}"
);
std::fs::remove_file(&gi).unwrap();
let _ = std::process::Command::new("git")
.args(["add", "."]).current_dir(fx.path()).output();
let staged = std::process::Command::new("git")
.args(["diff", "--cached", "--name-only"])
.current_dir(fx.path())
.output()
.expect("git diff");
let staged = String::from_utf8_lossy(&staged.stdout);
assert!(
staged.contains(".reliary/index.sqlite"),
"negative control failed: without the guard the index MUST be stageable, got: {staged}"
);
}
#[test]
fn e2e_cli_init_closed_stdin_exits_nonzero() {
use std::process::{Command, Stdio};
let home = fake_home();
let out = Command::new(crate::binary_path())
.args(["init"])
.current_dir(home.path())
.env("HOME", home.path())
.env("NO_RELIARY_WATCHER", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("spawn init");
assert!(!out.status.success(), "init with closed stdin must fail closed");
assert_eq!(out.status.code(), Some(2), "exit code must be 2, got {:?}", out.status.code());
let claude: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(home.path().join(".claude.json")).unwrap(),
).unwrap();
assert!(claude["mcpServers"]["reliary"].is_null(), "fail-closed init must not wire anything: {}", claude);
}
#[test]
fn e2e_cli_clean_closed_stdin_refuses() {
use std::process::{Command, Stdio};
let fx = Fixture::new();
fx.trust();
assert!(fx.path().join(".reliary").exists());
let out = Command::new(crate::binary_path())
.args(["clean"])
.current_dir(fx.path())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("spawn clean");
assert_eq!(out.status.code(), Some(2), "clean with closed stdin must exit 2");
assert!(fx.path().join(".reliary").exists(), "fail-closed clean must not delete");
}
#[test]
fn e2e_cli_status_path_flag() {
let fx = Fixture::new();
fx.trust();
let out = std::process::Command::new(crate::binary_path())
.args(["status", "--path", &fx.path().to_string_lossy()])
.current_dir("/tmp")
.output()
.expect("spawn status");
assert!(out.status.success());
let text = String::from_utf8_lossy(&out.stdout).to_string();
assert!(text.contains("files indexed"), "status --path must report the foreign index: {}", text);
}
#[test]
fn e2e_cli_search_no_index_exits_two() {
let tmp = tempfile::tempdir().unwrap();
let out = std::process::Command::new(crate::binary_path())
.args(["search", "foo", &tmp.path().to_string_lossy()])
.stdin(std::process::Stdio::null())
.output()
.expect("spawn search");
assert_eq!(out.status.code(), Some(2), "search with no index must exit 2");
}
#[test]
fn e2e_cli_cache_retrieve_falls_back_to_tee() {
let fx = Fixture::new();
fx.trust();
let raw = "tee-fallback-probe-output";
let hash = {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(raw.as_bytes()))
};
let tee_dir = std::path::PathBuf::from("/tmp/reliary-tee");
std::fs::create_dir_all(&tee_dir).ok(); std::fs::write(tee_dir.join(&hash), raw).unwrap();
let out = std::process::Command::new(crate::binary_path())
.args(["cache-retrieve", &hash])
.current_dir(fx.path())
.output()
.expect("spawn cache-retrieve");
assert!(out.status.success(), "tee fallback must succeed");
assert_eq!(String::from_utf8_lossy(&out.stdout), raw);
let _ = std::fs::remove_file(tee_dir.join(&hash));
}
#[test]
fn e2e_cli_cache_retrieve_rejects_traversal() {
let fx = Fixture::new();
fx.trust();
let out = std::process::Command::new(crate::binary_path())
.args(["cache-retrieve", "../../etc/passwd"])
.current_dir(fx.path())
.output()
.expect("spawn cache-retrieve");
assert!(!out.status.success(), "traversal tee name must be refused");
}