use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
fn hook_payload(file: &Path) -> String {
serde_json::json!({ "tool_input": { "file_path": file } }).to_string()
}
fn binary() -> PathBuf {
let mut path = std::env::current_exe().expect("test binary path");
path.pop();
if path.ends_with("deps") {
path.pop();
}
path.join("recall")
}
struct Run {
code: i32,
stdout: String,
stderr: String,
}
fn run(args: &[&str], cwd: &Path, env: &[(&str, &str)], stdin: Option<&str>) -> Run {
let mut cmd = command(args, cwd, env);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("failed to run the recall binary");
if let Some(input) = stdin {
child
.stdin
.as_mut()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
}
drop(child.stdin.take());
let out = child.wait_with_output().unwrap();
Run {
code: out.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
}
}
fn command(args: &[&str], cwd: &Path, env: &[(&str, &str)]) -> Command {
let mut cmd = Command::new(binary());
cmd.args(args)
.current_dir(cwd)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", cwd.to_string_lossy().to_string());
#[cfg(windows)]
for var in ["SystemRoot", "windir", "TEMP", "TMP", "LOCALAPPDATA"] {
if let Ok(v) = std::env::var(var) {
cmd.env(var, v);
}
}
for (k, v) in env {
cmd.env(k, v);
}
cmd
}
struct Repo {
_dir: tempfile::TempDir,
path: PathBuf,
}
impl Repo {
fn path(&self) -> &Path {
&self.path
}
}
fn git_repo() -> Repo {
let dir = tempfile::tempdir().unwrap();
for args in [
vec!["init", "-q"],
vec!["remote", "add", "origin", "git@github.com:acme/app.git"],
] {
assert!(Command::new("git")
.args(&args)
.current_dir(dir.path())
.status()
.unwrap()
.success());
}
let path = std::fs::canonicalize(dir.path()).unwrap_or_else(|_| dir.path().to_path_buf());
let path = strip_verbatim_prefix(path);
Repo { _dir: dir, path }
}
#[cfg(windows)]
fn strip_verbatim_prefix(path: PathBuf) -> PathBuf {
match path.to_str() {
Some(s) => match s.strip_prefix(r"\\?\") {
Some(rest) if !rest.starts_with(r"UNC\") => PathBuf::from(rest),
_ => path,
},
None => path,
}
}
#[cfg(not(windows))]
fn strip_verbatim_prefix(path: PathBuf) -> PathBuf {
path
}
#[test]
fn the_repo_helper_agrees_with_git_about_where_the_repo_is() {
let repo = git_repo();
let out = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(repo.path())
.output()
.unwrap();
assert!(out.status.success(), "git rev-parse failed in the fixture");
let git_says = String::from_utf8_lossy(&out.stdout).trim().to_string();
let git_says = native_separators(git_says);
assert_eq!(
repo.path().display().to_string(),
git_says,
"the tests build expected paths from one spelling and the binary \
reports another, so every assertion naming a path is comparing two \
different strings"
);
}
#[cfg(windows)]
fn native_separators(path: String) -> String {
path.replace('/', "\\")
}
#[cfg(not(windows))]
fn native_separators(path: String) -> String {
path
}
const DEAD_SERVER: &str = "http://127.0.0.1:9";
#[test]
fn pull_exits_zero_when_the_server_is_unreachable() {
let repo = git_repo();
let r = run(
&["pull"],
repo.path(),
&[("RECALL_URL", DEAD_SERVER), ("RECALL_TOKEN", "t")],
None,
);
assert_eq!(
r.code, 0,
"pull must not fail a session start\n{}",
r.stderr
);
assert!(
r.stderr.contains("leaving local memory untouched"),
"the user should still be told, on stderr: {:?}",
r.stderr
);
}
#[test]
fn pull_exits_zero_when_nothing_is_configured() {
let repo = git_repo();
let r = run(&["pull"], repo.path(), &[], None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(r.stderr.contains("RECALL_URL"), "stderr: {:?}", r.stderr);
}
#[test]
fn push_is_a_silent_no_op_for_a_file_that_is_not_memory() {
let repo = git_repo();
let payload = hook_payload(&repo.path().join("src/main.rs"));
let r = run(&["push"], repo.path(), &[], Some(&payload));
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(r.stderr.is_empty(), "expected silence, got {:?}", r.stderr);
}
#[test]
fn push_ignores_a_malformed_hook_payload() {
let repo = git_repo();
for payload in ["", "not json", "{}", r#"{"tool_input":{}}"#, "[1,2,3]"] {
let r = run(&["push"], repo.path(), &[], Some(payload));
assert_eq!(r.code, 0, "payload {payload:?} -> stderr {}", r.stderr);
assert!(
r.stderr.is_empty(),
"payload {payload:?} produced noise: {:?}",
r.stderr
);
}
}
#[cfg(unix)]
fn hostname_shim() -> (tempfile::TempDir, PathBuf, String) {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("hostname-was-run");
let script = dir.path().join("hostname");
std::fs::write(
&script,
format!("#!/bin/sh\n: > '{}'\necho shimmed\n", marker.display()),
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = format!(
"{}:{}",
dir.path().display(),
std::env::var("PATH").unwrap_or_default()
);
(dir, marker, path)
}
#[cfg(unix)]
#[test]
fn push_does_not_fork_hostname_before_deciding_a_file_is_not_its_business() {
let repo = git_repo();
let (_shim, marker, path) = hostname_shim();
let r = run(&["status"], repo.path(), &[("PATH", &path)], None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
marker.exists(),
"the shim did not run even where configuration *is* resolved, so \
nothing below could have detected a fork: {}",
r.stdout
);
std::fs::remove_file(&marker).unwrap();
let payload = hook_payload(&repo.path().join("src/main.rs"));
let r = run(&["push"], repo.path(), &[("PATH", &path)], Some(&payload));
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
!marker.exists(),
"push forked hostname(1) for a file it went on to ignore — that is \
one spawned process per edit in every session"
);
}
#[test]
fn init_refuses_outside_a_git_repository() {
let dir = tempfile::tempdir().unwrap();
let r = run(&["init"], dir.path(), &[], None);
assert_eq!(r.code, 1, "stdout: {} stderr: {}", r.stdout, r.stderr);
assert!(
r.stderr.contains("git repository"),
"the message should say why: {:?}",
r.stderr
);
}
#[test]
fn init_is_idempotent_and_says_so() {
let repo = git_repo();
let first = run(&["init"], repo.path(), &[], None);
assert_eq!(first.code, 0, "stderr: {}", first.stderr);
assert!(
first.stdout.contains("✓ Wired Recall's hooks into"),
"{}",
first.stdout
);
assert!(
first
.stdout
.contains("→ git add .claude/settings.json && git commit"),
"{}",
first.stdout
);
assert!(!first.stdout.contains("git -C"), "{}", first.stdout);
let second = run(&["init"], repo.path(), &[], None);
assert_eq!(second.code, 0);
assert!(
second.stdout.contains("already wired"),
"a second run should not claim to have done work: {:?}",
second.stdout
);
assert!(
!second.stdout.contains("git add"),
"nothing new to commit: {}",
second.stdout
);
let settings =
std::fs::read_to_string(repo.path().join(".claude").join("settings.json")).unwrap();
assert_eq!(
settings.matches("recall push").count(),
1,
"the hook was duplicated:\n{settings}"
);
}
#[test]
fn init_warns_about_unset_variables_without_failing() {
let repo = git_repo();
let r = run(&["init"], repo.path(), &[], None);
assert_eq!(r.code, 0);
assert!(r.stdout.contains("RECALL_URL"), "stdout: {}", r.stdout);
assert!(r.stdout.contains("RECALL_TOKEN"), "stdout: {}", r.stdout);
assert!(
r.stdout.contains("→ recall connect https://"),
"and what supplies them: {}",
r.stdout
);
}
fn fix_after<'a>(out: &'a str, marked: &str) -> Option<&'a str> {
out.lines()
.skip_while(|l| !l.contains(marked))
.skip(1)
.take_while(|l| l.starts_with(" "))
.map(str::trim)
.find(|l| l.starts_with('→'))
}
fn words(out: &str) -> String {
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[test]
fn status_reports_rather_than_fails_when_unconfigured() {
let repo = git_repo();
let r = run(&["status"], repo.path(), &[], None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(r.stdout.contains("acme/app"), "stdout: {}", r.stdout);
assert!(
r.stdout.contains("✗ RECALL_URL") && r.stdout.contains("not set"),
"stdout: {}",
r.stdout
);
assert!(
r.stdout.contains("✗ hooks"),
"an unwired project should say so plainly: {}",
r.stdout
);
assert_eq!(
fix_after(&r.stdout, "✗ hooks"),
Some("→ recall init"),
"{}",
r.stdout
);
assert!(
fix_after(&r.stdout, "✗ RECALL_URL").is_some_and(|f| f.contains("recall connect")),
"{}",
r.stdout
);
assert!(
!r.stdout.contains(&repo.path().display().to_string()),
"home is shown as ~: {}",
r.stdout
);
assert!(
r.stdout.contains(" problems. ") || r.stdout.contains(" problem. "),
"a closing line: {}",
r.stdout
);
}
#[test]
fn status_json_is_parseable_and_reports_an_unreachable_server() {
let repo = git_repo();
let r = run(
&["status", "--json"],
repo.path(),
&[("RECALL_URL", DEAD_SERVER), ("RECALL_TOKEN", "t")],
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
let parsed: serde_json::Value = serde_json::from_str(&r.stdout)
.unwrap_or_else(|e| panic!("--json did not emit JSON ({e}): {}", r.stdout));
assert_eq!(parsed["project_key"], "acme/app");
assert_eq!(parsed["server_ok"], false);
assert!(
parsed["server_error"].is_string(),
"an unreachable server should be explained: {parsed}"
);
}
#[test]
fn status_works_outside_a_git_repository() {
let dir = tempfile::tempdir().unwrap();
let r = run(&["status", "--json"], dir.path(), &[], None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
let parsed: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
assert!(
parsed["project_key"]
.as_str()
.unwrap()
.starts_with("local:"),
"expected a local: fallback key, got {parsed}"
);
}
#[test]
fn status_reports_a_declared_project_key_and_where_it_came_from() {
let repo = git_repo();
let r = run(
&["status", "--json"],
repo.path(),
&[("RECALL_PROJECT_KEY", "Acme/Monorepo-Api")],
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
let parsed: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
assert_eq!(
parsed["project_key"], "acme/monorepo-api",
"a declaration beats the git remote, normalised: {parsed}"
);
assert_eq!(parsed["project_key_source"], "declared");
assert!(
parsed.get("rejected_vars").is_none(),
"nothing was refused, so nothing should be listed: {parsed}"
);
}
#[test]
fn status_says_when_a_declared_project_key_was_refused() {
let repo = git_repo();
let r = run(
&["status"],
repo.path(),
&[("RECALL_PROJECT_KEY", "global:eko")],
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stdout.contains("acme/app"),
"the derived key still stands: {}",
r.stdout
);
assert!(
words(&r.stdout).contains("RECALL_PROJECT_KEY is set but unusable"),
"a refused declaration has to be visible: {}",
r.stdout
);
assert!(
fix_after(&r.stdout, "✗ key").is_some_and(|f| f.contains("RECALL_PROJECT_KEY")),
"and says what would make it usable: {}",
r.stdout
);
}
fn home_elsewhere() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
fn write_settings(repo: &Path, name: &str, body: &str) -> String {
let dir = repo.join(".claude");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, body).unwrap();
path.display().to_string()
}
fn status_json(cwd: &Path, env: &[(&str, &str)]) -> serde_json::Value {
let r = run(&["status", "--json"], cwd, env, None);
assert_eq!(r.code, 0, "status must never fail: {}", r.stderr);
serde_json::from_str(&r.stdout)
.unwrap_or_else(|e| panic!("--json did not emit JSON ({e}): {}", r.stdout))
}
fn declared_entry<'a>(rep: &'a serde_json::Value, name: &str) -> &'a serde_json::Value {
rep["declared_env"]
.as_array()
.unwrap_or_else(|| panic!("nothing was reported as declared at all: {rep}"))
.iter()
.find(|var| var["name"] == name)
.unwrap_or_else(|| panic!("{name} was not reported as declared: {rep}"))
}
#[test]
fn status_reads_a_project_key_that_only_the_settings_file_declares() {
let repo = git_repo();
let file = write_settings(
repo.path(),
"settings.json",
r#"{"env":{"RECALL_PROJECT_KEY":"acme/monorepo-api"}}"#,
);
let home = home_elsewhere();
let home = home.path().to_string_lossy().into_owned();
let rep = status_json(repo.path(), &[("HOME", &home)]);
assert_eq!(
rep["project_key"], "acme/monorepo-api",
"the committed declaration is what the hooks sync under, and nothing \
in this shell said so: {rep}"
);
assert_eq!(rep["project_key_source"], "declared");
let var = declared_entry(&rep, "RECALL_PROJECT_KEY");
assert_eq!(
var["file"], file,
"the report has to name the file to go and edit: {rep}"
);
assert_eq!(
var["shadows_shell"], false,
"nothing in this shell was overridden, and saying otherwise sends \
someone hunting an export that does not exist: {rep}"
);
}
#[test]
fn a_settings_declaration_beats_the_shell_and_not_the_other_way_round() {
let repo = git_repo();
let file = write_settings(
repo.path(),
"settings.json",
r#"{"env":{"RECALL_PROJECT_KEY":"acme/from-settings"}}"#,
);
let home = home_elsewhere();
let home = home.path().to_string_lossy().into_owned();
let rep = status_json(
repo.path(),
&[
("HOME", &home),
("RECALL_PROJECT_KEY", "acme/from-the-shell"),
],
);
assert_eq!(
rep["project_key"], "acme/from-settings",
"the settings file is the layer that wins: {rep}"
);
assert_ne!(
rep["project_key"], "acme/from-the-shell",
"the shell value is replaced, not preferred: {rep}"
);
let var = declared_entry(&rep, "RECALL_PROJECT_KEY");
assert_eq!(var["file"], file);
assert_eq!(
var["shadows_shell"], true,
"a shell value that is set and not in effect is exactly the \
disagreement worth naming: {rep}"
);
}
#[test]
fn the_local_settings_file_wins_and_is_the_file_status_names() {
let repo = git_repo();
write_settings(
repo.path(),
"settings.json",
r#"{"env":{"RECALL_PROJECT_KEY":"acme/committed"}}"#,
);
let local = write_settings(
repo.path(),
"settings.local.json",
r#"{"env":{"RECALL_PROJECT_KEY":"acme/untracked"}}"#,
);
let home = home_elsewhere();
let home = home.path().to_string_lossy().into_owned();
let rep = status_json(repo.path(), &[("HOME", &home)]);
assert_eq!(
rep["project_key"], "acme/untracked",
"the higher-precedence file is the one in force: {rep}"
);
assert_eq!(
declared_entry(&rep, "RECALL_PROJECT_KEY")["file"],
local,
"the report must point at the file that actually won: {rep}"
);
}
#[test]
fn unreadable_settings_are_a_finding_rather_than_a_failure() {
let repo = git_repo();
let file = write_settings(repo.path(), "settings.json", "{ not json");
let home = home_elsewhere();
let home = home.path().to_string_lossy().into_owned();
let r = run(&["status", "--json"], repo.path(), &[("HOME", &home)], None);
assert_eq!(
r.code, 0,
"a broken settings file must not take the diagnostic down with it: {}",
r.stderr
);
let rep: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let unreadable = rep["unreadable_settings"]
.as_array()
.unwrap_or_else(|| panic!("a file that cannot be parsed was skipped silently: {rep}"));
assert!(
unreadable.iter().any(|f| *f == file),
"the report has to name the file: {rep}"
);
assert_eq!(
rep["hooks_wired"], false,
"the same unparseable file is where hooks would have been found: {rep}"
);
assert_eq!(
rep["project_key"], "acme/app",
"with the declaration unreadable the key falls back to the remote: {rep}"
);
}
#[test]
fn an_empty_declaration_turns_a_setting_off_and_hides_the_shell_value() {
let repo = git_repo();
write_settings(
repo.path(),
"settings.json",
r#"{"env":{"RECALL_GLOBAL_KEY":""}}"#,
);
let home = home_elsewhere();
let home = home.path().to_string_lossy().into_owned();
let rep = status_json(
repo.path(),
&[("HOME", &home), ("RECALL_GLOBAL_KEY", "eko")],
);
let var = declared_entry(&rep, "RECALL_GLOBAL_KEY");
assert_eq!(
var["empty"], true,
"an empty declaration is a declaration, not an absence: {rep}"
);
assert_eq!(
var["shadows_shell"], true,
"the shell's usable value is the thing being hidden: {rep}"
);
assert!(
rep.get("global_key").is_none(),
"global scope is off, however much the shell exported: {rep}"
);
}
#[test]
fn the_text_report_names_the_file_that_overrides_the_shell() {
let repo = git_repo();
let file = write_settings(
repo.path(),
"settings.json",
r#"{"env":{"RECALL_PROJECT_KEY":"acme/from-settings"}}"#,
);
let home = home_elsewhere();
let home = home.path().to_string_lossy().into_owned();
let r = run(
&["status"],
repo.path(),
&[
("HOME", &home),
("RECALL_PROJECT_KEY", "acme/from-the-shell"),
],
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
let text = words(&r.stdout);
assert!(
text.contains(&file),
"the settings file has to appear by path: {}",
r.stdout
);
assert!(
text.contains("overrides the value set in this shell"),
"the shell value being dead has to be said, not implied: {}",
r.stdout
);
assert!(
text.contains(&format!("set by {file}")),
"the project_key line should attribute the key to that file too: {}",
r.stdout
);
}
#[test]
fn the_user_settings_file_is_the_lowest_layer_and_is_still_read() {
let repo = git_repo();
let home = home_elsewhere();
let user_file = write_settings(
home.path(),
"settings.json",
r#"{"env":{"RECALL_PROJECT_KEY":"acme/from-home","RECALL_GLOBAL_KEY":"eko-home"}}"#,
);
let project_file = write_settings(
repo.path(),
"settings.json",
r#"{"env":{"RECALL_PROJECT_KEY":"acme/from-the-project"}}"#,
);
let home = home.path().to_string_lossy().into_owned();
let rep = status_json(repo.path(), &[("HOME", &home)]);
assert_eq!(
rep["project_key"], "acme/from-the-project",
"the project's file sits above the user's: {rep}"
);
assert_eq!(
declared_entry(&rep, "RECALL_PROJECT_KEY")["file"],
project_file,
"the winning layer is the one to name: {rep}"
);
assert_eq!(
declared_entry(&rep, "RECALL_GLOBAL_KEY")["file"],
user_file,
"a variable no higher layer declares still comes from the user file, \
which is how we know it was opened: {rep}"
);
assert_eq!(
rep["global_key"], "global:eko-home",
"and the value it supplies is actually in force, namespaced the way \
any accepted global key is: {rep}"
);
}
#[test]
fn a_non_string_declaration_is_reported_rather_than_silently_dropped() {
let repo = git_repo();
let file = write_settings(
repo.path(),
"settings.json",
r#"{"env":{"RECALL_PROJECT_KEY":12345}}"#,
);
let home = home_elsewhere();
let home = home.path().to_string_lossy().into_owned();
let rep = status_json(repo.path(), &[("HOME", &home)]);
let ignored = rep["ignored_env"]
.as_array()
.unwrap_or_else(|| panic!("a declaration that sets nothing went unremarked: {rep}"));
assert!(
ignored
.iter()
.any(|var| var["name"] == "RECALL_PROJECT_KEY" && var["file"] == file),
"the report has to name both the variable and the file: {rep}"
);
let declared: Vec<&str> = rep["declared_env"]
.as_array()
.map(|vars| vars.iter().filter_map(|var| var["name"].as_str()).collect())
.unwrap_or_default();
assert!(
!declared.contains(&"RECALL_PROJECT_KEY"),
"a variable that was never set must not also be reported as declared, \
or the two halves of the report contradict each other: {rep}"
);
assert_eq!(
rep["project_key"], "acme/app",
"nothing was set, so the key is the derived one: {rep}"
);
}
#[test]
fn promote_refuses_when_there_is_no_global_scope_to_promote_into() {
let repo = git_repo();
let r = run(
&["promote", "user.md"],
repo.path(),
&[("RECALL_URL", DEAD_SERVER), ("RECALL_TOKEN", "t")],
None,
);
assert_eq!(r.code, 1, "stdout: {} stderr: {}", r.stdout, r.stderr);
assert!(
r.stderr.contains("RECALL_GLOBAL_KEY"),
"the refusal should name the variable to set: {}",
r.stderr
);
}
#[test]
fn promote_refuses_a_note_that_is_not_there() {
let repo = git_repo();
let r = run(
&["promote", "topics/nothing-here.md"],
repo.path(),
&[
("RECALL_URL", DEAD_SERVER),
("RECALL_TOKEN", "t"),
("RECALL_GLOBAL_KEY", "eko"),
],
None,
);
assert_eq!(r.code, 1, "stdout: {} stderr: {}", r.stdout, r.stderr);
assert!(r.stderr.contains("does not exist"), "stderr: {}", r.stderr);
}
#[test]
fn promote_refuses_a_path_that_climbs_out_of_the_memory_directory() {
let repo = git_repo();
let r = run(
&["promote", "../../../../.ssh/id_rsa"],
repo.path(),
&[
("RECALL_URL", DEAD_SERVER),
("RECALL_TOKEN", "t"),
("RECALL_GLOBAL_KEY", "eko"),
],
None,
);
assert_eq!(r.code, 1, "stdout: {} stderr: {}", r.stdout, r.stderr);
assert!(
r.stderr.contains("not a memory file"),
"stderr: {}",
r.stderr
);
}
#[test]
fn promote_is_loud_about_missing_configuration() {
let repo = git_repo();
let r = run(&["promote", "user.md"], repo.path(), &[], None);
assert_eq!(r.code, 1, "stdout: {} stderr: {}", r.stdout, r.stderr);
assert!(r.stderr.contains("RECALL_URL"), "stderr: {}", r.stderr);
}
const COMMANDS: &[&str] = &[
"connect",
"init",
"backfill",
"disconnect",
"status",
"doctor",
"promote",
"review",
"eval",
"devices",
"authkey",
"audit",
"push",
"pull",
"version",
"help",
];
#[test]
fn version_prints_and_exits_zero() {
let dir = tempfile::tempdir().unwrap();
let r = run(&["version"], dir.path(), &[], None);
assert_eq!(r.code, 0);
assert!(r.stdout.starts_with("recall "), "stdout: {}", r.stdout);
}
#[test]
fn the_three_ways_to_ask_for_the_version_give_the_same_answer() {
let dir = tempfile::tempdir().unwrap();
let sub = run(&["version"], dir.path(), &[], None);
let long = run(&["--version"], dir.path(), &[], None);
let short = run(&["-V"], dir.path(), &[], None);
for (name, r) in [("version", &sub), ("--version", &long), ("-V", &short)] {
assert_eq!(r.code, 0, "{name} exited {}: {}", r.code, r.stderr);
}
assert_eq!(
long.stdout, sub.stdout,
"`--version` and `version` disagree, so one of them is lying"
);
assert_eq!(short.stdout, sub.stdout, "`-V` drifted from the other two");
}
#[test]
fn help_answers_to_every_form_and_names_every_command() {
let dir = tempfile::tempdir().unwrap();
for form in [vec!["--help"], vec!["-h"], vec!["help"]] {
let r = run(&form, dir.path(), &[], None);
assert_eq!(r.code, 0, "{form:?} exited {}: {}", r.code, r.stderr);
for command in COMMANDS {
assert!(
r.stdout
.lines()
.any(|l| l.starts_with(" ") && l.split_whitespace().next() == Some(command)),
"{form:?} does not list `{command}`: {}",
r.stdout
);
}
}
assert!(
run(&["--help"], dir.path(), &[], None)
.stdout
.contains("--version"),
"the version flag should be discoverable from the help that mentions it"
);
}
#[test]
fn per_command_help_works_from_either_direction() {
let dir = tempfile::tempdir().unwrap();
let before = run(&["help", "status"], dir.path(), &[], None);
let after = run(&["status", "--help"], dir.path(), &[], None);
assert_eq!(before.code, 0, "stderr: {}", before.stderr);
assert_eq!(after.code, 0, "stderr: {}", after.stderr);
assert_eq!(before.stdout, after.stdout);
assert!(
before.stdout.contains("--json"),
"a command's own flags belong in its help: {}",
before.stdout
);
}
#[test]
fn no_arguments_prints_help_and_does_not_look_successful() {
let dir = tempfile::tempdir().unwrap();
let r = run(&[], dir.path(), &[], None);
assert_ne!(r.code, 0, "a bare `recall` should not read as success");
let combined = format!("{}{}", r.stdout, r.stderr);
assert!(
combined.contains("Usage") && combined.contains("init"),
"it should print the help it is refusing to guess at: {combined}"
);
}
#[test]
fn every_way_of_asking_for_help_starts_with_the_version() {
let dir = tempfile::tempdir().unwrap();
let version = run(&["version"], dir.path(), &[], None).stdout;
let version = version.trim_end();
assert!(version.starts_with("recall "), "{version}");
let forms: &[&[&str]] = &[
&["--help"],
&["-h"],
&["help"],
&[],
&["status", "--help"],
&["status", "-h"],
&["help", "status"],
&["devices", "approve", "--help"],
&["help", "devices", "approve"],
&["devices"],
];
for form in forms {
let r = run(form, dir.path(), &[], None);
let text = if r.stdout.is_empty() {
&r.stderr
} else {
&r.stdout
};
assert_eq!(
text.lines().next(),
Some(version),
"recall {form:?}:\n{text}"
);
}
}
#[test]
fn the_top_level_help_groups_the_commands_and_says_where_to_start() {
let dir = tempfile::tempdir().unwrap();
let r = run(&["--help"], dir.path(), &[], None);
let out = &r.stdout;
let headings: Vec<&str> = out
.lines()
.filter(|l| !l.starts_with(' ') && l.ends_with(':'))
.collect();
assert_eq!(
headings,
[
"Get started:",
"Every day:",
"Memory quality:",
"Your server:",
"Run by Claude Code (hooks):",
"Other:",
"Options:",
],
"{out}"
);
let first = out.lines().skip_while(|l| *l != "Get started:").nth(1);
assert!(
first.is_some_and(|l| l.split_whitespace().next() == Some("connect")),
"{out}"
);
assert!(out.contains("recall connect https://"), "{out}");
assert!(out.contains("recall help <command>"), "{out}");
assert!(
!out.lines()
.any(|l| l.split_whitespace().next() == Some("serve")),
"the hidden command should stay hidden: {out}"
);
}
#[test]
fn short_help_summarises_and_long_help_explains() {
let dir = tempfile::tempdir().unwrap();
let short = run(&["connect", "-h"], dir.path(), &[], None);
let long = run(&["connect", "--help"], dir.path(), &[], None);
assert_eq!(short.code, 0, "stderr: {}", short.stderr);
assert_eq!(long.code, 0, "stderr: {}", long.stderr);
let summary = short.stdout.lines().nth(2).unwrap_or_default();
assert!(!summary.is_empty(), "{}", short.stdout);
assert!(long.stdout.contains(summary), "{}", long.stdout);
assert!(
long.stdout.lines().count() > short.stdout.lines().count(),
"--help should say more than -h:\n{}\n{}",
short.stdout,
long.stdout
);
assert!(
short.stdout.contains("see more with '--help'"),
"{}",
short.stdout
);
}
#[test]
fn backfill_is_loud_about_missing_configuration() {
let repo = git_repo();
let r = run(&["backfill"], repo.path(), &[], None);
assert_eq!(r.code, 1, "stdout: {} stderr: {}", r.stdout, r.stderr);
assert!(
r.stderr.contains("RECALL_URL"),
"the refusal should name the variable to set: {}",
r.stderr
);
}
#[test]
fn backfill_exits_two_when_the_server_cannot_be_reached() {
let repo = git_repo();
std::fs::create_dir_all(repo.path().join(".claude")).unwrap();
let r = run(
&["backfill"],
repo.path(),
&[("RECALL_URL", DEAD_SERVER), ("RECALL_TOKEN", "t")],
None,
);
assert_eq!(r.code, 2, "stdout: {} stderr: {}", r.stdout, r.stderr);
assert!(
!r.stderr.is_empty(),
"an unreachable server has to say so rather than exit quietly"
);
assert!(
!r.stdout.contains(" sent,"),
"nothing was sent, so it must not print a summary that implies it was: {}",
r.stdout
);
}
#[test]
fn an_unknown_subcommand_fails_with_usage() {
let dir = tempfile::tempdir().unwrap();
let r = run(&["definitely-not-a-command"], dir.path(), &[], None);
assert_ne!(r.code, 0, "an unknown command should not look successful");
let combined = format!("{}{}", r.stdout, r.stderr);
assert!(
combined.contains("recall") && combined.contains("init"),
"the failure should point at the real commands: {combined}"
);
}
#[test]
fn serve_says_the_server_moved_to_recall_server() {
let dir = tempfile::tempdir().unwrap();
let r = run(&["serve"], dir.path(), &[], None);
assert_eq!(r.code, 1, "stdout: {} stderr: {}", r.stdout, r.stderr);
assert!(
r.stderr.contains("recall-server"),
"the message should name the new binary: {:?}",
r.stderr
);
}
#[test]
fn doctor_exits_non_zero_where_status_exits_zero() {
let repo = git_repo();
let status = run(&["status"], repo.path(), &[], None);
let doctor = run(&["doctor"], repo.path(), &[], None);
assert_eq!(status.code, 0, "status must stay informational");
assert_eq!(
doctor.code, 1,
"doctor must fail an unconfigured project: {}",
doctor.stdout
);
}
#[test]
fn doctor_names_what_is_missing_and_what_to_do() {
let repo = git_repo();
let r = run(&["doctor"], repo.path(), &[], None);
assert!(r.stdout.contains("RECALL_URL"), "stdout: {}", r.stdout);
assert!(r.stdout.contains("RECALL_TOKEN"), "stdout: {}", r.stdout);
assert!(
r.stdout.contains("recall init"),
"an unwired project should be told the command that wires it: {}",
r.stdout
);
}
#[test]
fn doctor_fails_a_remote_session_with_no_memory_dir() {
let repo = git_repo();
let r = run(
&["doctor"],
repo.path(),
&[("CLAUDE_CODE_REMOTE", "true")],
None,
);
assert_eq!(r.code, 1, "stdout: {}", r.stdout);
assert!(
r.stdout.contains("✗ CLAUDE_CODE_REMOTE_MEMORY_DIR"),
"stdout: {}",
r.stdout
);
assert!(
r.stdout.contains("/home/user/.claude"),
"the value has to be in the output: {}",
r.stdout
);
}
#[test]
fn doctor_says_nothing_about_the_memory_dir_outside_a_remote_session() {
let repo = git_repo();
let r = run(&["doctor"], repo.path(), &[], None);
assert!(
!r.stdout.contains("✗ CLAUDE_CODE_REMOTE_MEMORY_DIR")
&& !r.stdout.contains("! CLAUDE_CODE_REMOTE_MEMORY_DIR"),
"stdout: {}",
r.stdout
);
}
#[test]
fn doctor_does_not_fail_on_unwired_hooks_outside_a_repository() {
let dir = tempfile::tempdir().unwrap();
let r = run(&["doctor", "--json"], dir.path(), &[], None);
let parsed: serde_json::Value = serde_json::from_str(&r.stdout)
.unwrap_or_else(|e| panic!("--json did not emit JSON ({e}): {}", r.stdout));
let hooks = parsed
.as_array()
.expect("doctor --json emits an array")
.iter()
.find(|f| f["check"] == "hooks")
.expect("hooks is checked");
assert_eq!(
hooks["level"], "ok",
"outside a repository there is nothing to wire: {hooks}"
);
}
#[test]
fn doctor_json_is_parseable_and_carries_the_levels() {
let repo = git_repo();
let r = run(&["doctor", "--json"], repo.path(), &[], None);
assert_eq!(r.code, 1, "stderr: {}", r.stderr);
let parsed: serde_json::Value = serde_json::from_str(&r.stdout)
.unwrap_or_else(|e| panic!("--json did not emit JSON ({e}): {}", r.stdout));
let found = parsed.as_array().expect("doctor --json emits an array");
let url = found
.iter()
.find(|f| f["check"] == "RECALL_URL")
.expect("RECALL_URL is checked");
assert_eq!(url["level"], "fail");
assert!(url["fix"].is_string(), "a fail carries a fix: {url}");
}
#[test]
fn doctor_passes_a_wired_project_that_can_reach_a_server() {
let repo = git_repo();
let init = run(&["init"], repo.path(), &[], None);
assert_eq!(init.code, 0, "stderr: {}", init.stderr);
let r = run(
&["doctor"],
repo.path(),
&[("RECALL_URL", DEAD_SERVER), ("RECALL_TOKEN", "t")],
None,
);
assert!(
r.stdout.contains("✓ hooks"),
"init wired the hooks, doctor should see it: {}",
r.stdout
);
assert!(
r.stdout.contains("✓ RECALL_URL") && r.stdout.contains("✓ RECALL_TOKEN"),
"both variables were set: {}",
r.stdout
);
assert!(
r.stdout.contains("✗ server"),
"and the dead server is what is left: {}",
r.stdout
);
}
fn recall_home_with(servers: &[(&str, &str)], server: &str) -> tempfile::TempDir {
recall_home_named(servers, server, None)
}
fn recall_home_named(
servers: &[(&str, &str)],
server: &str,
name: Option<&str>,
) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let mut creds = String::from("version = 1\n");
for (url, token) in servers {
creds.push_str(&format!("\n[servers.\"{url}\"]\ntoken = \"{token}\"\n"));
}
let creds_path = dir.path().join("credentials.toml");
std::fs::write(&creds_path, creds).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&creds_path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
let mut config = String::from("version = 1\n");
if !server.is_empty() {
config.push_str(&format!("server = \"{server}\"\n"));
}
if let Some(name) = name {
config.push_str(&format!("\n[machine]\nname = \"{name}\"\n"));
}
std::fs::write(dir.path().join("config.toml"), config).unwrap();
dir
}
#[test]
fn a_0_3_0_credentials_file_is_migrated_by_the_first_command() {
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let legacy = home.path().join("credentials.json");
std::fs::write(
&legacy,
format!(
r#"{{"version":1,"default":"{DEAD_SERVER}","servers":{{"{DEAD_SERVER}":{{"token":"t"}}}}}}"#
),
)
.unwrap();
let home_str = home.path().to_string_lossy().to_string();
let rep = status_json(repo.path(), &[("RECALL_HOME", &home_str)]);
assert!(!legacy.exists(), "the old file is gone");
assert_eq!(rep["url_source"], "config_file", "{rep}");
assert_eq!(rep["token_source"], "credentials_file", "{rep}");
let config = std::fs::read_to_string(home.path().join("config.toml")).unwrap();
assert!(
config.contains(&format!("server = \"{DEAD_SERVER}\"")),
"{config}"
);
}
#[test]
fn the_machine_name_in_config_turns_on_the_machine_scope() {
let repo = git_repo();
let home = recall_home_named(&[(DEAD_SERVER, "t")], DEAD_SERVER, Some("jarvis"));
let home_str = home.path().to_string_lossy().to_string();
let rep = status_json(repo.path(), &[("RECALL_HOME", &home_str)]);
assert_eq!(rep["machine_key"], "machine:jarvis", "{rep}");
assert_eq!(rep["machine_source"], "config_file", "{rep}");
let rep = status_json(
repo.path(),
&[("RECALL_HOME", &home_str), ("RECALL_SOURCE_ENV", "laptop")],
);
let o = &rep["overridden"][0];
assert_eq!(o["variable"], "RECALL_SOURCE_ENV", "{rep}");
assert_eq!(o["environment"], "laptop");
assert_eq!(o["config"], "jarvis");
let rep = status_json(
repo.path(),
&[
("RECALL_HOME", &home_str),
("RECALL_MACHINE_KEY", "machine:jarvis"),
],
);
assert!(rep.get("overridden").is_none(), "{rep}");
}
#[test]
fn connect_refuses_in_a_remote_session_and_writes_nothing() {
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let r = run(
&["connect", "https://recall.example.com"],
repo.path(),
&[("CLAUDE_CODE_REMOTE", "true"), ("RECALL_HOME", &home_str)],
None,
);
assert_eq!(r.code, 1, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("remote session"),
"refused for being remote: {}",
r.stderr
);
assert!(
r.stderr.contains("RECALL_TOKEN"),
"and says what to do instead: {}",
r.stderr
);
assert!(!home.path().join("credentials.toml").exists());
}
#[test]
fn connect_without_a_terminal_refuses_and_writes_nothing() {
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let r = run(
&["connect", "https://recall.example.com"],
repo.path(),
&[("RECALL_HOME", &home_str)],
Some("s3cret\n"),
);
assert_eq!(r.code, 1, "stderr: {}", r.stderr);
assert!(r.stderr.contains("terminal"), "stderr: {}", r.stderr);
assert!(!home.path().join("credentials.toml").exists());
}
#[test]
fn connect_refuses_something_that_is_not_a_server_url() {
let repo = git_repo();
let r = run(&["connect", "recall.example.com"], repo.path(), &[], None);
assert_eq!(r.code, 1);
assert!(
r.stderr.contains("not a server URL"),
"stderr: {}",
r.stderr
);
}
#[test]
fn connect_will_not_overwrite_a_credentials_file_it_cannot_read() {
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let path = home.path().join("credentials.toml");
std::fs::write(&path, "{ this is not toml").unwrap();
let home_str = home.path().to_string_lossy().to_string();
let r = run(
&["connect", "https://recall.example.com"],
repo.path(),
&[("RECALL_HOME", &home_str)],
None,
);
assert_eq!(r.code, 1, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("Nothing was changed"),
"stderr: {}",
r.stderr
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"{ this is not toml"
);
}
#[test]
fn status_reads_the_url_and_token_that_connect_saved() {
let repo = git_repo();
let home = recall_home_with(&[(DEAD_SERVER, "t")], DEAD_SERVER);
let home_str = home.path().to_string_lossy().to_string();
let rep = status_json(repo.path(), &[("RECALL_HOME", &home_str)]);
assert_eq!(rep["url_set"], true, "{rep}");
assert_eq!(rep["token_set"], true, "{rep}");
assert_eq!(rep["url_source"], "config_file");
assert_eq!(rep["token_source"], "credentials_file");
assert_eq!(rep["credentials_exposed"], false);
}
#[test]
fn an_exported_token_still_wins_over_the_saved_one() {
let repo = git_repo();
let home = recall_home_with(&[(DEAD_SERVER, "saved")], DEAD_SERVER);
let home_str = home.path().to_string_lossy().to_string();
let rep = status_json(
repo.path(),
&[("RECALL_HOME", &home_str), ("RECALL_TOKEN", "exported")],
);
assert_eq!(rep["token_source"], "environment", "{rep}");
assert_eq!(rep["url_source"], "config_file", "{rep}");
}
#[test]
fn doctor_suggests_connect_for_a_shell_token_on_a_laptop() {
let repo = git_repo();
let r = run(
&["doctor"],
repo.path(),
&[("RECALL_URL", DEAD_SERVER), ("RECALL_TOKEN", "t")],
None,
);
assert!(r.stdout.contains("! token storage"), "stdout: {}", r.stdout);
assert!(
fix_after(&r.stdout, "! token storage").is_some_and(|f| f.contains("recall connect")),
"stdout: {}",
r.stdout
);
}
#[test]
fn disconnect_removes_the_saved_token_and_is_honest_about_the_shell() {
let repo = git_repo();
let home = recall_home_with(
&[("https://recall.example.com", "t")],
"https://recall.example.com",
);
let home_str = home.path().to_string_lossy().to_string();
let r = run(
&["disconnect"],
repo.path(),
&[("RECALL_HOME", &home_str), ("RECALL_TOKEN", "exported")],
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
!home.path().join("credentials.toml").exists(),
"the last server gone, the file goes too"
);
assert!(
r.stdout
.contains("Removed the token for https://recall.example.com"),
"{}",
r.stdout
);
assert!(
r.stdout.contains("still works on the server"),
"{}",
r.stdout
);
assert_eq!(
fix_after(&r.stdout, "! Your shell still supplies RECALL_TOKEN"),
Some("→ remove it from your shell profile"),
"{}",
r.stdout
);
assert!(
r.stdout.contains("! Removed what was saved here"),
"and the closing line does not claim more than that: {}",
r.stdout
);
assert!(
!r.stdout.contains(".zshrc") && !r.stdout.contains(".zprofile"),
"no guessing: {}",
r.stdout
);
}
#[test]
fn disconnect_asks_which_when_it_cannot_tell() {
let repo = git_repo();
let home = recall_home_with(
&[
("https://a.example.com", "ta"),
("https://b.example.com", "tb"),
],
"",
);
let path = home.path().join("credentials.toml");
let home_str = home.path().to_string_lossy().to_string();
let r = run(
&["disconnect"],
repo.path(),
&[("RECALL_HOME", &home_str)],
None,
);
assert_eq!(r.code, 1);
assert!(
r.stderr.contains("recall disconnect https://a.example.com"),
"{}",
r.stderr
);
assert!(path.exists(), "nothing was removed");
}
#[test]
fn push_says_so_when_the_file_is_another_projects_memory() {
let repo = git_repo();
let foreign = repo
.path()
.join(".claude/projects/-somewhere-else/memory/note.md");
std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();
std::fs::write(&foreign, "# note\n").unwrap();
let payload = hook_payload(&foreign);
let r = run(&["push"], repo.path(), &[], Some(&payload));
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("-somewhere-else"),
"the slug has to be named, or the reader cannot tell which project: {}",
r.stderr
);
assert!(
r.stderr.contains("nothing was pushed"),
"and it has to say nothing happened: {}",
r.stderr
);
}
#[test]
fn push_stays_silent_about_an_ordinary_file() {
let repo = git_repo();
let source = repo.path().join("src/main.rs");
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
std::fs::write(&source, "fn main() {}\n").unwrap();
let payload = hook_payload(&source);
let r = run(&["push"], repo.path(), &[], Some(&payload));
assert_eq!(r.code, 0);
assert_eq!(r.stderr.trim(), "", "an ordinary edit is not worth a word");
}
struct LiveServer {
url: String,
stop: Option<tokio::sync::oneshot::Sender<()>>,
thread: Option<std::thread::JoinHandle<()>>,
_db: tempfile::TempDir,
cfg: recall_server::Config,
store: std::sync::Arc<recall_server::Store>,
}
impl LiveServer {
fn sweep_every_ephemeral_device(&self) {
let cfg = recall_server::Config {
ephemeral_device_ttl: std::time::Duration::ZERO,
..self.cfg.clone()
};
std::thread::sleep(std::time::Duration::from_millis(5));
recall_server::Server::new(cfg, self.store.clone())
.sweep_devices()
.unwrap();
}
}
impl LiveServer {
fn restart_after(mut self, edit: impl FnOnce(&Path)) -> LiveServer {
let port: u16 = self.url.rsplit(':').next().unwrap().parse().unwrap();
if let Some(stop) = self.stop.take() {
let _ = stop.send(());
}
if let Some(thread) = self.thread.take() {
thread.join().unwrap();
}
let db = std::mem::replace(&mut self._db, tempfile::tempdir().unwrap());
let token = self.cfg.token.clone();
drop(self);
edit(&db.path().join("recall.db"));
let listener = (0..50)
.find_map(|_| {
std::net::TcpListener::bind(("127.0.0.1", port))
.map_err(|_| std::thread::sleep(std::time::Duration::from_millis(100)))
.ok()
})
.expect("the same port again");
serve(db, &token, 60, listener)
}
}
impl Drop for LiveServer {
fn drop(&mut self) {
if let Some(stop) = self.stop.take() {
let _ = stop.send(());
}
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
fn live_server(token: &str) -> LiveServer {
live_server_started(token, 60)
}
fn live_server_just_started(token: &str) -> LiveServer {
let into_second = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_millis();
live_server_started(token, if into_second < 500 { 4 } else { 3 })
}
fn live_server_started(token: &str, seconds_ago: i64) -> LiveServer {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
serve(tempfile::tempdir().unwrap(), token, seconds_ago, listener)
}
fn serve(
db: tempfile::TempDir,
token: &str,
seconds_ago: i64,
listener: std::net::TcpListener,
) -> LiveServer {
let cfg = recall_server::Config {
token: token.to_string(),
db_path: db.path().join("recall.db").to_string_lossy().to_string(),
merge_enabled: false,
..Default::default()
};
let store = std::sync::Arc::new(recall_server::Store::open(&cfg.db_path).expect("store opens"));
let (kept_cfg, kept_store) = (cfg.clone(), store.clone());
listener.set_nonblocking(true).unwrap();
let port = listener.local_addr().unwrap().port();
let (stop, stopped) = tokio::sync::oneshot::channel::<()>();
let thread = std::thread::spawn(move || {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async move {
let listener = tokio::net::TcpListener::from_std(listener).unwrap();
let server = recall_server::Server::new(cfg, store);
server.backdate_start(seconds_ago);
server
.serve_with_shutdown(listener, async {
let _ = stopped.await;
})
.await
.unwrap();
});
});
LiveServer {
url: format!("http://127.0.0.1:{port}"),
stop: Some(stop),
thread: Some(thread),
_db: db,
cfg: kept_cfg,
store: kept_store,
}
}
#[test]
fn connect_with_a_working_saved_token_sets_the_machine_up_without_asking() {
let server = live_server("right");
let repo = git_repo();
let home = recall_home_with(&[(&server.url, "right")], &server.url);
let home_str = home.path().to_string_lossy().to_string();
let r = run(
&["connect", "--yes", "--name", "jarvis"],
repo.path(),
&[("RECALL_HOME", &home_str)],
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(r.stderr.contains("Saved token OK"), "stderr: {}", r.stderr);
let config = std::fs::read_to_string(home.path().join("config.toml")).unwrap();
assert!(
config.contains("name = \"jarvis\""),
"config.toml: {config}"
);
assert!(
config.contains(&format!("server = \"{}\"", server.url)),
"config.toml: {config}"
);
let settings =
std::fs::read_to_string(repo.path().join(".claude").join("settings.json")).unwrap();
assert!(settings.contains("recall push"), "wired: {settings}");
assert!(!r.stderr.contains("Uploaded"), "stderr: {}", r.stderr);
assert!(
r.stderr
.contains("syncs from its next Claude Code session."),
"the closing line says what happens next: {}",
r.stderr
);
let again = run(
&["connect", "--yes"],
repo.path(),
&[("RECALL_HOME", &home_str)],
None,
);
assert_eq!(again.code, 0, "stderr: {}", again.stderr);
assert!(
again.stderr.contains("already syncs"),
"stderr: {}",
again.stderr
);
assert!(
again.stderr.contains("jarvis"),
"keeps the name: {}",
again.stderr
);
}
#[test]
fn backfill_sums_up_in_one_line_and_hides_the_zeros() {
let server = live_server("right");
let repo = git_repo();
let env = [
("RECALL_URL", server.url.as_str()),
("RECALL_TOKEN", "right"),
];
let memory_dir = status_json(repo.path(), &env)["memory_dir"]
.as_str()
.unwrap()
.to_string();
std::fs::create_dir_all(&memory_dir).unwrap();
for name in ["a", "b"] {
std::fs::write(
Path::new(&memory_dir).join(format!("{name}.md")),
format!("---\nname: {name}\ndescription: a fact\n---\n\nA fact.\n"),
)
.unwrap();
}
let first = run(&["backfill"], repo.path(), &env, None);
assert_eq!(first.code, 0, "stderr: {}", first.stderr);
assert!(
first.stdout.contains("✓ Sent all 2 files here."),
"{}",
first.stdout
);
let again = run(&["backfill"], repo.path(), &env, None);
assert_eq!(again.code, 0, "stderr: {}", again.stderr);
assert!(
again
.stdout
.contains("✓ Nothing to send: the server already has all 2 files."),
"{}",
again.stdout
);
for out in [&first.stdout, &again.stdout] {
assert!(!out.contains(" 0 "), "a zero that is not the answer: {out}");
}
}
#[test]
fn promote_says_where_the_note_went_and_who_sees_it() {
let server = live_server("right");
let repo = git_repo();
let env = [
("RECALL_URL", server.url.as_str()),
("RECALL_TOKEN", "right"),
("RECALL_GLOBAL_KEY", "eko"),
];
let memory_dir = status_json(repo.path(), &env)["memory_dir"]
.as_str()
.unwrap()
.to_string();
std::fs::create_dir_all(&memory_dir).unwrap();
std::fs::write(
Path::new(&memory_dir).join("user.md"),
"---\nname: user\ndescription: who I am\n---\n\nA person.\n",
)
.unwrap();
let r = run(&["promote", "user.md"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stdout.contains("✓ Moved user.md to global/user.md"),
"{}",
r.stdout
);
assert!(
r.stdout
.contains("✓ Every other project picks it up at its next session start."),
"{}",
r.stdout
);
}
#[test]
fn connect_sends_existing_memory_when_it_wires_a_project() {
let server = live_server("right");
let repo = git_repo();
let home = recall_home_with(&[(&server.url, "right")], &server.url);
let home_str = home.path().to_string_lossy().to_string();
let env = [("RECALL_HOME", home_str.as_str())];
let memory_dir = status_json(repo.path(), &env)["memory_dir"]
.as_str()
.unwrap()
.to_string();
std::fs::create_dir_all(&memory_dir).unwrap();
std::fs::write(
Path::new(&memory_dir).join("fact.md"),
"---\nname: fact\ndescription: a fact\n---\n\nA fact.\n",
)
.unwrap();
let r = run(
&["connect", "--yes", "--name", "jarvis"],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(r.stderr.contains("Uploaded"), "stderr: {}", r.stderr);
assert!(!r.stderr.contains("Uploaded 0"), "stderr: {}", r.stderr);
}
#[test]
fn connect_with_a_rejected_saved_token_and_no_terminal_changes_nothing() {
let server = live_server("right");
let repo = git_repo();
let home = recall_home_with(&[(&server.url, "stale")], &server.url);
let home_str = home.path().to_string_lossy().to_string();
let before = std::fs::read_to_string(home.path().join("config.toml")).unwrap();
let r = run(
&["connect", "--yes", "--name", "jarvis"],
repo.path(),
&[("RECALL_HOME", &home_str)],
None,
);
assert_eq!(r.code, 1, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("needs a new token"),
"stderr: {}",
r.stderr
);
assert_eq!(
std::fs::read_to_string(home.path().join("config.toml")).unwrap(),
before
);
assert!(!repo.path().join(".claude").join("settings.json").exists());
}
#[test]
fn connect_with_no_server_named_or_saved_says_to_name_one() {
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let r = run(
&["connect"],
repo.path(),
&[("RECALL_HOME", &home_str)],
None,
);
assert_eq!(r.code, 1, "stderr: {}", r.stderr);
assert!(r.stderr.contains("no server named"), "stderr: {}", r.stderr);
assert!(
r.stderr.contains("recall connect https://"),
"stderr: {}",
r.stderr
);
}
#[test]
fn connect_refuses_an_unusable_name_before_anything_else() {
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let r = run(
&["connect", DEAD_SERVER, "--name", "my laptop"],
repo.path(),
&[("RECALL_HOME", &home_str)],
None,
);
assert_eq!(r.code, 1, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("not a usable machine name"),
"stderr: {}",
r.stderr
);
assert!(!r.stderr.contains("reach"), "stderr: {}", r.stderr);
}
#[test]
fn connect_names_the_machine_variables_a_shell_profile_still_exports() {
let server = live_server("right");
let repo = git_repo();
let home = recall_home_with(&[(&server.url, "right")], &server.url);
let home_str = home.path().to_string_lossy().to_string();
let r = run(
&["connect", "--yes", "--name", "jarvis"],
repo.path(),
&[
("RECALL_HOME", &home_str),
("RECALL_SOURCE_ENV", "laptop"),
("RECALL_MACHINE_KEY", "machine:jarvis"),
],
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stderr
.contains("RECALL_SOURCE_ENV=laptop overrides the name jarvis"),
"stderr: {}",
r.stderr
);
assert!(
r.stderr
.contains("remove from your shell profile: RECALL_MACHINE_KEY"),
"stderr: {}",
r.stderr
);
}
fn block_on<F: std::future::Future>(fut: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(fut)
}
fn operator(server: &LiveServer) -> recall_hooks::client::Client {
recall_hooks::client::Client::new(&server.url, "right").unwrap()
}
fn saved_device(home: &Path, url: &str) -> Option<recall_hooks::home::DeviceEntry> {
recall_hooks::home::Home::at(home)
.load_devices()
.unwrap()
.and_then(|d| d.for_url(url).cloned())
}
fn enrolled(server: &LiveServer, repo: &Repo, name: &str) -> tempfile::TempDir {
let home = recall_home_with(&[(&server.url, "right")], &server.url);
let home_str = home.path().to_string_lossy().to_string();
let r = run(
&["connect", "--yes", "--name", name],
repo.path(),
&[("RECALL_HOME", &home_str)],
None,
);
assert_eq!(r.code, 0, "connect failed: {}", r.stderr);
home
}
fn push_memory(repo: &Repo, env: &[(&str, &str)], name: &str, body: &str) -> Run {
let memory_dir = status_json(repo.path(), env)["memory_dir"]
.as_str()
.unwrap()
.to_string();
std::fs::create_dir_all(&memory_dir).unwrap();
let file = Path::new(&memory_dir).join(name);
std::fs::write(&file, body).unwrap();
run(&["push"], repo.path(), env, Some(&hook_payload(&file)))
}
fn stored(server: &LiveServer, repo: &Repo, env: &[(&str, &str)]) -> Vec<recall_wire::File> {
let key = status_json(repo.path(), env)["project_key"]
.as_str()
.unwrap()
.to_string();
block_on(operator(server).pull(&key)).unwrap().files
}
fn doctor_finding(cwd: &Path, env: &[(&str, &str)], check: &str) -> serde_json::Value {
let r = run(&["doctor", "--json"], cwd, env, None);
let found: serde_json::Value = serde_json::from_str(&r.stdout)
.unwrap_or_else(|e| panic!("doctor --json is not JSON ({e}): {}", r.stdout));
found
.as_array()
.unwrap()
.iter()
.find(|f| f["check"] == check)
.cloned()
.unwrap_or_else(|| panic!("no {check} finding: {found}"))
}
fn pending_enrollment(
url: &str,
name: &str,
) -> (recall_hooks::device::DeviceKey, recall_wire::EnrollPending) {
use recall_hooks::client::{Client, Enrolled};
let key = recall_hooks::device::DeviceKey::generate().unwrap();
let open = Client::new(url, "").unwrap();
match block_on(open.enroll(&key.enroll_request(name, None))).unwrap() {
Enrolled::Pending(pending) => (key, pending),
other => panic!("expected to wait for approval, got {other:?}"),
}
}
#[test]
fn connect_enrolls_the_first_machine_and_approves_it_with_the_operator_token() {
let server = live_server("right");
let repo = git_repo();
let home = recall_home_with(&[(&server.url, "right")], &server.url);
let home_str = home.path().to_string_lossy().to_string();
let env = [("RECALL_HOME", home_str.as_str())];
let r = run(
&["connect", "--yes", "--name", "jarvis"],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
let device = saved_device(home.path(), &server.url).expect("a device key is saved");
assert_eq!(
(device.name.as_str(), device.scope.as_str()),
("jarvis", "admin")
);
assert!(!device.ephemeral);
let listed = block_on(operator(&server).devices()).unwrap().devices;
let on_server = listed
.iter()
.find(|d| d.id == device.device_id)
.expect("the server knows the device this machine saved");
assert!(
r.stderr
.contains(&format!("Fingerprint {}", on_server.fingerprint)),
"the fingerprint shown is the one the server holds: {}",
r.stderr
);
assert!(r.stderr.contains("Code "), "stderr: {}", r.stderr);
assert!(
r.stderr.contains("Removed the shared token"),
"stderr: {}",
r.stderr
);
assert!(
!home.path().join("credentials.toml").exists(),
"the token is no longer kept once it is not needed"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(home.path().join("device.key"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "the private key is the owner's alone");
}
let list = run(&["devices", "list", "--json"], repo.path(), &env, None);
assert_eq!(list.code, 0, "stderr: {}", list.stderr);
let doc: serde_json::Value = serde_json::from_str(&list.stdout).unwrap();
assert_eq!(doc["devices"][0]["name"], "jarvis", "{doc}");
let text = run(&["devices", "list"], repo.path(), &env, None);
assert!(
text.stdout.contains("jarvis") && text.stdout.contains("this machine"),
"{}",
text.stdout
);
assert!(text.stdout.contains("NAME"), "{}", text.stdout);
assert!(
text.stdout.lines().all(|l| l == l.trim_end()),
"{}",
text.stdout
);
let fingerprint = doc["devices"][0]["fingerprint"].as_str().unwrap();
let bare = fingerprint.trim_start_matches("SHA256:");
assert!(
text.stdout.contains(&format!("{}…", &bare[..8])) && !text.stdout.contains(bare),
"{}",
text.stdout
);
let rep = status_json(repo.path(), &env);
assert_eq!(rep["auth"], "device", "{rep}");
assert_eq!(rep["device"]["name"], "jarvis", "{rep}");
assert_eq!(rep["device"]["confirmed"], true, "{rep}");
assert_eq!(rep["device"]["key_storage"], "file", "{rep}");
assert_eq!(rep["server_devices"], true, "{rep}");
let finding = doctor_finding(repo.path(), &env, "device");
assert_eq!(finding["level"], "ok", "{finding}");
assert!(
finding["detail"]
.as_str()
.unwrap()
.contains("enrolled as jarvis (admin)"),
"{finding}"
);
assert_eq!(
doctor_finding(repo.path(), &env, "RECALL_TOKEN")["level"],
"ok"
);
let again = run(&["connect", "--yes"], repo.path(), &env, None);
assert_eq!(again.code, 0, "stderr: {}", again.stderr);
assert!(
again.stderr.contains("Enrolled as jarvis (admin)"),
"stderr: {}",
again.stderr
);
assert_eq!(
saved_device(home.path(), &server.url).unwrap().device_id,
device.device_id
);
}
#[test]
fn a_device_signs_its_pushes_and_pulls_and_the_name_belongs_to_the_key() {
let server = live_server("right");
let repo = git_repo();
let home = enrolled(&server, &repo, "jarvis");
let home_str = home.path().to_string_lossy().to_string();
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_SOURCE_ENV", "not-jarvis"),
];
let r = push_memory(&repo, &env, "fact.md", "A fact.\n");
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(r.stderr.contains("pushed 1"), "stderr: {}", r.stderr);
let files = stored(&server, &repo, &env);
let fact = files
.iter()
.find(|f| f.file_path == "fact.md")
.expect("the push arrived");
assert_eq!(fact.source_env, "jarvis");
let pulled = run(&["pull"], repo.path(), &env, None);
assert_eq!(pulled.code, 0, "stderr: {}", pulled.stderr);
assert!(
pulled.stderr.contains("synced 1 memory file(s)"),
"a signed pull: {}",
pulled.stderr
);
}
#[test]
fn devices_approve_can_make_a_worker() {
use recall_hooks::client::{Client, Poll};
let server = live_server("right");
let repo = git_repo();
let (_key, pending) = pending_enrollment(&server.url, "worker");
let open = Client::new(&server.url, "").unwrap();
let env = [
("RECALL_URL", server.url.as_str()),
("RECALL_TOKEN", "right"),
];
let r = run(
&[
"devices",
"approve",
&pending.user_code,
"--worker",
"--admin",
"--yes",
],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 2, "stderr: {}", r.stderr);
assert!(!matches!(
block_on(open.poll(&pending.enrollment_id)).unwrap(),
Poll::Approved(_)
));
let r = run(
&[
"devices",
"approve",
&pending.user_code,
"--worker",
"--yes",
],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stdout.contains("Approved worker (worker)"),
"stdout: {}",
r.stdout
);
match block_on(open.poll(&pending.enrollment_id)).unwrap() {
Poll::Approved(approved) => assert_eq!(approved.scope, "worker"),
other => panic!("expected approval, got {other:?}"),
}
}
#[test]
fn devices_approve_refuses_a_key_whose_fingerprint_is_not_the_one_given() {
use recall_hooks::client::{Client, Poll};
let server = live_server("right");
let repo = git_repo();
let (key, pending) = pending_enrollment(&server.url, "phone");
let open = Client::new(&server.url, "").unwrap();
let env = [
("RECALL_URL", server.url.as_str()),
("RECALL_TOKEN", "right"),
];
let someone_else = recall_hooks::device::DeviceKey::generate().unwrap();
let r = run(
&[
"devices",
"approve",
&pending.user_code,
"--fingerprint",
&someone_else.fingerprint(),
"--yes",
],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 1, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("Nothing was approved"),
"stderr: {}",
r.stderr
);
assert!(
r.stderr.contains(&key.fingerprint()) && r.stderr.contains("phone"),
"what the code would approve is shown: {}",
r.stderr
);
assert!(!matches!(
block_on(open.poll(&pending.enrollment_id)).unwrap(),
Poll::Approved(_)
));
let r = run(
&["devices", "approve", &pending.user_code.to_lowercase()],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 1, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("needs a terminal"),
"stderr: {}",
r.stderr
);
let bare = key.fingerprint().trim_start_matches("SHA256:").to_string();
let r = run(
&[
"devices",
"approve",
&pending.user_code,
"--fingerprint",
&bare,
"--yes",
],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stdout.contains("Approved phone (sync)"),
"stdout: {}",
r.stdout
);
match block_on(open.poll(&pending.enrollment_id)).unwrap() {
Poll::Approved(approved) => assert_eq!(approved.scope, "sync"),
other => panic!("expected approval, got {other:?}"),
}
}
#[test]
fn authkey_create_shows_the_key_alone_and_list_marks_a_revoked_one() {
let server = live_server("right");
let repo = git_repo();
let env = [
("RECALL_URL", server.url.as_str()),
("RECALL_TOKEN", "right"),
];
let made = run(
&["authkey", "create", "--tag", "cloud", "--expires", "90d"],
repo.path(),
&env,
None,
);
assert_eq!(made.code, 0, "stderr: {}", made.stderr);
let keys = block_on(operator(&server).authkeys()).unwrap().authkeys;
let id = keys[0].id.clone();
let key = made
.stdout
.lines()
.find(|l| l.starts_with("recall-ak-"))
.unwrap_or_else(|| panic!("the key on a line of its own: {}", made.stdout));
assert_eq!(key.trim_end(), key, "{}", made.stdout);
assert!(
made.stdout.contains("expires in 90 days"),
"{}",
made.stdout
);
assert!(
made.stdout
.contains(&format!("→ recall authkey revoke {id}")),
"{}",
made.stdout
);
let other = block_on(
operator(&server).create_authkey(&recall_wire::AuthkeyRequest {
tag: "old".into(),
expires_in_days: 1,
ephemeral: true,
max_devices: None,
}),
)
.unwrap();
let revoked = run(&["authkey", "revoke", &other.id], repo.path(), &env, None);
assert_eq!(revoked.code, 0, "stderr: {}", revoked.stderr);
assert!(
revoked.stdout.contains(&format!(
"→ recall authkey revoke {} --revoke-devices",
other.id
)),
"{}",
revoked.stdout
);
let list = run(&["authkey", "list"], repo.path(), &env, None);
assert_eq!(list.code, 0, "stderr: {}", list.stderr);
let live = list.stdout.find(&id).expect("the live key is listed");
let gone = list
.stdout
.find(&other.id)
.expect("the revoked key is listed");
assert!(live < gone, "the live key first: {}", list.stdout);
let row = list.stdout.lines().find(|l| l.contains(&other.id)).unwrap();
assert!(
row.trim_start().starts_with('â—‹') && row.contains("revoked just now"),
"{}",
list.stdout
);
assert!(list.stdout.contains("1 in use"), "{}", list.stdout);
let missing = run(&["authkey", "revoke", "ak_nope"], repo.path(), &env, None);
assert_eq!(missing.code, 2, "{}", missing.stderr);
assert!(
missing.stderr.starts_with("recall authkey: "),
"named after the command run: {}",
missing.stderr
);
}
#[test]
fn a_cloud_session_enrolls_itself_at_its_first_pull_with_an_enrolment_key() {
let server = live_server("right");
let repo = git_repo();
let key = block_on(
operator(&server).create_authkey(&recall_wire::AuthkeyRequest {
tag: "cloud".into(),
expires_in_days: 1,
ephemeral: true,
max_devices: None,
}),
)
.unwrap();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_URL", server.url.as_str()),
("RECALL_AUTHKEY", key.key.as_str()),
];
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("enrolled this session as device cloud-"),
"stderr: {}",
r.stderr
);
assert!(
!r.stderr.contains("leaving local memory untouched"),
"and then it pulled: {}",
r.stderr
);
let device = saved_device(home.path(), &server.url).expect("the key is kept");
assert!(device.ephemeral);
assert_eq!(device.scope, "sync");
let again = run(&["pull"], repo.path(), &env, None);
assert_eq!(again.code, 0, "stderr: {}", again.stderr);
assert!(
!again.stderr.contains("enrolled"),
"stderr: {}",
again.stderr
);
assert_eq!(
saved_device(home.path(), &server.url).unwrap().device_id,
device.device_id
);
let r = push_memory(&repo, &env, "cloud.md", "From the cloud.\n");
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
let files = stored(&server, &repo, &env);
let cloud = files.iter().find(|f| f.file_path == "cloud.md").unwrap();
assert_eq!(cloud.source_env, device.name);
let r = run(&["devices", "list"], repo.path(), &env, None);
assert_eq!(r.code, 2, "stderr: {}", r.stderr);
assert!(r.stderr.contains("sync scope"), "stderr: {}", r.stderr);
}
#[test]
fn a_shell_edit_survives_the_next_pull_and_recall_sync_sends_one() {
let server = live_server("right");
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_URL", server.url.as_str()),
("RECALL_TOKEN", "right"),
];
let r = push_memory(&repo, &env, "notes.md", "v1\n");
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
let memory_dir = write_memory(repo.path(), &env, "notes.md", "v1\nfrom the shell\n");
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("first sent 1 local change(s)"),
"stderr: {}",
r.stderr
);
let notes = Path::new(&memory_dir).join("notes.md");
assert_eq!(
std::fs::read_to_string(¬es).unwrap(),
"v1\nfrom the shell\n",
"the pull overwrote an edit no hook had seen"
);
let files = stored(&server, &repo, &env);
let stored_notes = files.iter().find(|f| f.file_path == "notes.md").unwrap();
assert_eq!(
stored_notes.content.as_deref(),
Some("v1\nfrom the shell\n")
);
std::fs::write(Path::new(&memory_dir).join("later.md"), "made with cat\n").unwrap();
let r = run(&["sync"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(r.stderr.contains("recall-sync: "), "stderr: {}", r.stderr);
let files = stored(&server, &repo, &env);
assert!(
files.iter().any(|f| f.file_path == "later.md"),
"recall sync did not send a file created through the shell"
);
}
#[test]
fn recall_sync_exits_non_zero_when_it_cannot_reach_the_server() {
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_URL", "http://127.0.0.1:9"),
("RECALL_TOKEN", "t"),
];
let r = run(&["sync"], repo.path(), &env, None);
assert_eq!(r.code, 2, "stderr: {}", r.stderr);
assert!(r.stderr.contains("recall-sync:"), "stderr: {}", r.stderr);
assert_eq!(run(&["pull"], repo.path(), &env, None).code, 0);
}
fn ephemeral_authkey(server: &LiveServer) -> String {
block_on(
operator(server).create_authkey(&recall_wire::AuthkeyRequest {
tag: "cloud".into(),
expires_in_days: 1,
ephemeral: true,
max_devices: None,
}),
)
.unwrap()
.key
}
fn made_up_device(id: &str, name: &str, ephemeral: bool) -> recall_hooks::home::DeviceEntry {
recall_hooks::device::DeviceKey::generate()
.unwrap()
.entry(id, name, "admin", ephemeral)
}
fn enrolled_again(stderr: &str) -> bool {
stderr.contains("enrolling again") || stderr.contains("enrolled this session")
}
fn devices_on(server: &LiveServer) -> Vec<recall_wire::Device> {
block_on(operator(server).devices()).unwrap().devices
}
#[test]
fn a_cloud_session_enrolls_again_after_its_device_is_swept() {
let server = live_server("right");
let repo = git_repo();
let key = ephemeral_authkey(&server);
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_URL", server.url.as_str()),
("RECALL_AUTHKEY", key.as_str()),
];
assert_eq!(run(&["pull"], repo.path(), &env, None).code, 0);
let first = saved_device(home.path(), &server.url).unwrap();
let elsewhere = "https://elsewhere.example.com";
let other = made_up_device("dev_elsewhere", "jarvis", false);
recall_hooks::home::Home::at(home.path())
.save_device(elsewhere, other.clone())
.unwrap();
server.sweep_every_ephemeral_device();
let r = push_memory(&repo, &env, "after-sweep.md", "Still here.\n");
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("(unknown device), enrolling again"),
"stderr: {}",
r.stderr
);
let second = saved_device(home.path(), &server.url).unwrap();
assert_ne!(second.device_id, first.device_id);
assert!(second.ephemeral);
let files = stored(&server, &repo, &env);
let file = files
.iter()
.find(|f| f.file_path == "after-sweep.md")
.expect("the push went through after enrolling again");
assert_eq!(file.source_env, second.name);
assert_eq!(
saved_device(home.path(), elsewhere).as_ref(),
Some(&other),
"another server's key is kept"
);
server.sweep_every_ephemeral_device();
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("(unknown device), enrolling again"),
"stderr: {}",
r.stderr
);
assert!(
!r.stderr.contains("leaving local memory untouched"),
"stderr: {}",
r.stderr
);
let third = saved_device(home.path(), &server.url).unwrap();
assert_ne!(third.device_id, second.device_id);
assert_eq!(saved_device(home.path(), elsewhere).as_ref(), Some(&other));
}
#[test]
fn a_revoked_device_is_not_enrolled_again_even_with_an_authkey() {
let server = live_server("right");
let repo = git_repo();
let key = ephemeral_authkey(&server);
let cloud = tempfile::tempdir().unwrap();
let cloud_str = cloud.path().to_string_lossy().to_string();
let env = [
("RECALL_HOME", cloud_str.as_str()),
("RECALL_URL", server.url.as_str()),
("RECALL_AUTHKEY", key.as_str()),
];
assert_eq!(run(&["pull"], repo.path(), &env, None).code, 0);
let session = saved_device(cloud.path(), &server.url).unwrap();
block_on(operator(&server).revoke_device(&session.device_id)).unwrap();
let r = push_memory(&repo, &env, "after-revoke.md", "Not synced.\n");
assert_eq!(r.code, 2, "the push is refused: {}", r.stderr);
assert!(
r.stderr.contains("this device has been revoked")
&& r.stderr.contains("not enrolled again")
&& !r.stderr.contains("enrolling again"),
"stderr: {}",
r.stderr
);
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0, "a session must still start: {}", r.stderr);
assert!(
r.stderr.contains("not enrolled again"),
"stderr: {}",
r.stderr
);
assert_eq!(
saved_device(cloud.path(), &server.url).as_ref(),
Some(&session),
"the revoked key is kept, so the next hook is refused too"
);
assert_eq!(devices_on(&server).len(), 1);
let laptop = enrolled(&server, &repo, "jarvis");
let laptop_str = laptop.path().to_string_lossy().to_string();
let admin = saved_device(laptop.path(), &server.url).unwrap();
assert_eq!(admin.scope, "admin");
block_on(operator(&server).revoke_device(&admin.device_id)).unwrap();
let env = [
("RECALL_HOME", laptop_str.as_str()),
("RECALL_AUTHKEY", key.as_str()),
];
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("not enrolled again") && r.stderr.contains("recall connect"),
"stderr: {}",
r.stderr
);
let r = push_memory(&repo, &env, "laptop.md", "Not synced either.\n");
assert_eq!(r.code, 2, "stderr: {}", r.stderr);
assert_eq!(
saved_device(laptop.path(), &server.url).as_ref(),
Some(&admin)
);
assert_eq!(devices_on(&server).len(), 2, "no new device");
}
#[test]
fn a_lasting_device_the_server_does_not_know_is_left_to_connect() {
let server = live_server("right");
let repo = git_repo();
let key = ephemeral_authkey(&server);
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let lasting = made_up_device("dev_neverenrolledhere", "jarvis", false);
recall_hooks::home::Home::at(home.path())
.save_device(&server.url, lasting.clone())
.unwrap();
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_URL", server.url.as_str()),
("RECALL_AUTHKEY", key.as_str()),
];
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("unknown device") && r.stderr.contains("run recall connect"),
"stderr: {}",
r.stderr
);
assert!(!enrolled_again(&r.stderr), "stderr: {}", r.stderr);
assert_eq!(
saved_device(home.path(), &server.url).as_ref(),
Some(&lasting)
);
assert!(devices_on(&server).is_empty());
}
#[test]
fn a_signature_the_server_refuses_keeps_the_key_and_enrols_nothing() {
let server = live_server("right");
let repo = git_repo();
let key = ephemeral_authkey(&server);
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_URL", server.url.as_str()),
("RECALL_AUTHKEY", key.as_str()),
];
assert_eq!(run(&["pull"], repo.path(), &env, None).code, 0);
let session = saved_device(home.path(), &server.url).unwrap();
let wrong = recall_hooks::device::DeviceKey::generate().unwrap().entry(
&session.device_id,
&session.name,
&session.scope,
true,
);
recall_hooks::home::Home::at(home.path())
.save_device(&server.url, wrong.clone())
.unwrap();
let started = std::time::Instant::now();
let r = push_memory(&repo, &env, "fact.md", "A fact.\n");
assert_eq!(r.code, 2, "stderr: {}", r.stderr);
assert!(!enrolled_again(&r.stderr), "stderr: {}", r.stderr);
assert!(
started.elapsed() < std::time::Duration::from_secs(2),
"nor signed again and again"
);
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(!enrolled_again(&r.stderr), "stderr: {}", r.stderr);
assert_eq!(
saved_device(home.path(), &server.url).as_ref(),
Some(&wrong)
);
assert_eq!(devices_on(&server).len(), 1);
}
#[test]
fn hooks_that_start_at_once_enrol_one_device_between_them() {
const AT_ONCE: usize = 8;
let server = live_server("right");
let repo = git_repo();
let key = ephemeral_authkey(&server);
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_URL", server.url.as_str()),
("RECALL_AUTHKEY", key.as_str()),
];
let finish = |children: Vec<std::process::Child>| {
for child in children {
let out = child.wait_with_output().unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
};
let pulls = (0..AT_ONCE)
.map(|_| {
command(&["pull"], repo.path(), &env)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap()
})
.collect();
finish(pulls);
let listed = devices_on(&server);
assert_eq!(listed.len(), 1, "{listed:?}");
let first = saved_device(home.path(), &server.url).unwrap();
assert_eq!(listed[0].id, first.device_id);
server.sweep_every_ephemeral_device();
let memory_dir = status_json(repo.path(), &env)["memory_dir"]
.as_str()
.unwrap()
.to_string();
std::fs::create_dir_all(&memory_dir).unwrap();
let pushes = (0..AT_ONCE)
.map(|i| {
let file = Path::new(&memory_dir).join(format!("burst-{i}.md"));
std::fs::write(&file, format!("Edit {i}.\n")).unwrap();
let mut child = command(&["push"], repo.path(), &env)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(hook_payload(&file).as_bytes())
.unwrap();
child
})
.collect();
finish(pushes);
let second = saved_device(home.path(), &server.url).unwrap();
assert_ne!(second.device_id, first.device_id);
let listed = devices_on(&server);
let new: Vec<_> = listed.iter().filter(|d| d.id != first.device_id).collect();
assert_eq!(new.len(), 1, "{listed:?}");
assert_eq!(new[0].id, second.device_id);
let files = stored(&server, &repo, &env);
for i in 0..AT_ONCE {
assert!(
files.iter().any(|f| f.file_path == format!("burst-{i}.md")),
"burst-{i}.md was pushed: {files:?}"
);
}
}
#[test]
fn an_unreadable_device_key_is_no_reason_to_send_the_token() {
let fake = fake_server(release_before_devices);
let repo = git_repo();
let home = recall_home_with(&[(&fake.url, "right")], &fake.url);
let home_str = home.path().to_string_lossy().to_string();
std::fs::write(home.path().join("device.key"), "servers = [").unwrap();
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_TOKEN", "right"),
("RECALL_AUTHKEY", "recall-ak-whatever"),
];
let r = push_memory(&repo, &env, "fact.md", "A fact.\n");
assert_eq!(r.code, 1, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("device.key") && r.stderr.contains("RECALL_TOKEN included"),
"stderr: {}",
r.stderr
);
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("device.key") && r.stderr.contains("leaving local memory untouched"),
"stderr: {}",
r.stderr
);
let seen = fake.seen();
assert!(
seen.iter()
.all(|s| s.authorization.is_none() && !s.signed && s.path != "/sync"),
"{seen:?}"
);
assert!(
seen.iter().all(|s| !s.path.starts_with("/v1/")),
"nothing enrolled: {seen:?}"
);
}
#[cfg(unix)]
#[test]
fn a_device_key_others_can_read_is_reported_then_made_private_by_a_hook() {
use std::os::unix::fs::PermissionsExt;
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
recall_hooks::home::Home::at(home.path())
.save_device(DEAD_SERVER, made_up_device("dev_x", "jarvis", false))
.unwrap();
let key_file = home.path().join("device.key");
std::fs::set_permissions(&key_file, std::fs::Permissions::from_mode(0o644)).unwrap();
let mode = || std::fs::metadata(&key_file).unwrap().permissions().mode() & 0o777;
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_URL", DEAD_SERVER),
];
let rep = status_json(repo.path(), &env);
assert_eq!(rep["device_file_exposed"], true, "{rep}");
assert_eq!(mode(), 0o644, "status only reports");
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("was readable by other users"),
"stderr: {}",
r.stderr
);
assert_eq!(mode(), 0o600);
let again = run(&["pull"], repo.path(), &env, None);
assert!(
!again.stderr.contains("readable by other users"),
"said once: {}",
again.stderr
);
assert_eq!(status_json(repo.path(), &env)["device_file_exposed"], false);
}
#[test]
fn status_reports_a_damaged_device_key_as_the_devices_problem() {
let server = live_server("right");
let repo = git_repo();
let home = recall_home_with(&[(&server.url, "right")], &server.url);
let home_str = home.path().to_string_lossy().to_string();
let env = [("RECALL_HOME", home_str.as_str())];
std::fs::write(home.path().join("device.key"), "servers = [").unwrap();
let rep = status_json(repo.path(), &env);
assert_eq!(rep["server_ok"], true, "{rep}");
assert!(rep.get("server_error").is_none(), "{rep}");
assert!(rep["device_error"].is_string(), "{rep}");
assert_eq!(rep["auth"], "none", "{rep}");
assert_eq!(
doctor_finding(repo.path(), &env, "device key")["level"],
"fail"
);
std::fs::remove_file(home.path().join("device.key")).unwrap();
let mut damaged = made_up_device("dev_x", "jarvis", false);
damaged.private_key = "not a key".into();
recall_hooks::home::Home::at(home.path())
.save_device(&server.url, damaged)
.unwrap();
let rep = status_json(repo.path(), &env);
assert_eq!(rep["server_ok"], true, "{rep}");
assert!(rep.get("server_error").is_none(), "{rep}");
assert!(
rep["device_error"].as_str().unwrap().contains("damaged"),
"{rep}"
);
assert_eq!(rep["auth"], "none", "{rep}");
}
#[test]
fn a_revoked_laptop_is_told_to_connect_and_connect_enrolls_it_again() {
let server = live_server("right");
let repo = git_repo();
let home = enrolled(&server, &repo, "jarvis");
let home_str = home.path().to_string_lossy().to_string();
let env = [("RECALL_HOME", home_str.as_str())];
let first = saved_device(home.path(), &server.url).unwrap();
block_on(operator(&server).revoke_device(&first.device_id)).unwrap();
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0, "a session must still start: {}", r.stderr);
assert!(
r.stderr.contains("this device has been revoked") && r.stderr.contains("recall connect"),
"stderr: {}",
r.stderr
);
let finding = doctor_finding(repo.path(), &env, "device");
assert_eq!(finding["level"], "fail", "{finding}");
assert_eq!(finding["fix"], "recall connect", "{finding}");
let mut child = command(&["connect", "--yes"], repo.path(), &env)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let stderr = child.stderr.take().unwrap();
let (lines, seen) = std::sync::mpsc::channel::<String>();
let reader = std::thread::spawn(move || {
use std::io::BufRead;
for line in std::io::BufReader::new(stderr)
.lines()
.map_while(Result::ok)
{
let _ = lines.send(line);
}
});
let mut shown = Vec::new();
let code = loop {
let line = seen
.recv_timeout(std::time::Duration::from_secs(30))
.unwrap_or_else(|_| panic!("no code shown: {}", shown.join("\n")));
shown.push(line.clone());
if let Some(at) = line.find("Code ") {
break line[at + 5..].trim().to_string();
}
};
let approve = run(
&["devices", "approve", &code, "--yes"],
repo.path(),
&[
("RECALL_URL", server.url.as_str()),
("RECALL_TOKEN", "right"),
],
None,
);
assert_eq!(approve.code, 0, "stderr: {}", approve.stderr);
let status = child.wait().unwrap();
reader.join().unwrap();
shown.extend(seen.try_iter());
assert!(status.success(), "connect: {}", shown.join("\n"));
assert!(
shown.iter().any(|l| l.contains("recall devices approve")),
"it said how to approve it: {}",
shown.join("\n")
);
let second = saved_device(home.path(), &server.url).unwrap();
assert_ne!(second.device_id, first.device_id);
assert_eq!(second.scope, "sync", "approved by code, the narrow scope");
let r = run(&["pull"], repo.path(), &env, None);
assert!(!r.stderr.contains("revoked"), "stderr: {}", r.stderr);
}
#[derive(Debug, Clone)]
struct Seen {
method: String,
path: String,
authorization: Option<String>,
signed: bool,
body: String,
}
struct FakeServer {
url: String,
seen: std::sync::Arc<std::sync::Mutex<Vec<Seen>>>,
stop: Option<tokio::sync::oneshot::Sender<()>>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl FakeServer {
fn seen(&self) -> Vec<Seen> {
self.seen.lock().unwrap().clone()
}
}
impl Drop for FakeServer {
fn drop(&mut self) {
if let Some(stop) = self.stop.take() {
let _ = stop.send(());
}
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
fn fake_server(respond: fn(&Seen) -> (u16, serde_json::Value)) -> FakeServer {
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let record = seen.clone();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = listener.local_addr().unwrap().port();
let (stop, stopped) = tokio::sync::oneshot::channel::<()>();
let thread = std::thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async move {
let app = axum::Router::new().fallback(
move |method: axum::http::Method,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
body: axum::body::Bytes| async move {
let seen = Seen {
method: method.to_string(),
path: uri.path().to_string(),
authorization: headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(str::to_string),
signed: headers.contains_key("signature-input")
|| headers.contains_key("signature"),
body: String::from_utf8_lossy(&body).into_owned(),
};
let (code, reply) = respond(&seen);
record.lock().unwrap().push(seen);
(
axum::http::StatusCode::from_u16(code).unwrap(),
axum::Json(reply),
)
},
);
let listener = tokio::net::TcpListener::from_std(listener).unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = stopped.await;
})
.await
.unwrap();
});
});
FakeServer {
url: format!("http://127.0.0.1:{port}"),
seen,
stop: Some(stop),
thread: Some(thread),
}
}
#[test]
fn devices_approve_binds_the_approval_to_the_fingerprint_it_showed() {
const FINGERPRINT: &str = "SHA256:sWwtG+rRJiY5dk/bDuTTd0WZM2vUk0BM2ksRNsWfIGI";
let fake = fake_server(|seen| match (seen.method.as_str(), seen.path.as_str()) {
("GET", "/v1/devices/pending/WDJB-MJHT") => (
200,
serde_json::json!({
"user_code": "WDJB-MJHT",
"name": "phone",
"agent": "recall/0.4.1 (linux-x86_64)",
"fingerprint": FINGERPRINT,
"expires_in": 600
}),
),
("POST", "/v1/devices/approve") => (
200,
serde_json::to_value(recall_wire::Device {
id: "dev_x".into(),
name: "phone".into(),
scope: "sync".into(),
fingerprint: FINGERPRINT.into(),
..Default::default()
})
.unwrap(),
),
_ => (404, serde_json::json!({ "error": "not found" })),
});
let repo = git_repo();
let r = run(
&["devices", "approve", "wdjb mjht", "--yes"],
repo.path(),
&[("RECALL_URL", fake.url.as_str()), ("RECALL_TOKEN", "right")],
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
let seen = fake.seen();
let looked = seen
.iter()
.position(|s| s.path == "/v1/devices/pending/WDJB-MJHT")
.expect("looked the code up first");
let approved = seen
.iter()
.position(|s| s.path == "/v1/devices/approve")
.expect("then approved it");
assert!(looked < approved);
let sent: recall_wire::ApproveRequest = serde_json::from_str(&seen[approved].body).unwrap();
assert_eq!(sent.user_code, "WDJB-MJHT");
assert_eq!(sent.scope, "sync");
assert_eq!(sent.fingerprint.as_deref(), Some(FINGERPRINT));
}
fn release_before_devices(seen: &Seen) -> (u16, serde_json::Value) {
let authorized = seen.authorization.as_deref() == Some("Bearer right");
match (seen.method.as_str(), seen.path.as_str()) {
("GET", "/health") => (
200,
serde_json::to_value(recall_wire::Health {
status: "ok".into(),
..Default::default()
})
.unwrap(),
),
("GET", "/.well-known/recall") => (
200,
serde_json::json!({
"protocol": { "current": 1, "supported": [1] },
"server": { "version": "0.4.0", "build": { "channel": "release" } },
"min_client": "0.1.0",
"auth": { "methods": ["bearer"] },
"capabilities": { "merge_base": {} }
}),
),
(_, "/admin/stats" | "/sync") if !authorized => {
(401, serde_json::json!({ "error": "unauthorized" }))
}
("GET", "/admin/stats") => (200, serde_json::json!({})),
("GET", "/sync") => (200, serde_json::json!({ "project_key": "", "files": [] })),
("POST", "/sync") => (
200,
serde_json::to_value(recall_wire::PushResponse {
ok: true,
..Default::default()
})
.unwrap(),
),
_ => (404, serde_json::json!({ "error": "not found" })),
}
}
fn device_era(seen: &Seen, discovery: u16, me: u16) -> (u16, serde_json::Value) {
let operator = seen.authorization.as_deref() == Some("Bearer right");
match (seen.method.as_str(), seen.path.as_str()) {
("GET", "/health") => (
200,
serde_json::to_value(recall_wire::Health {
status: "ok".into(),
..Default::default()
})
.unwrap(),
),
("GET", "/.well-known/recall") if discovery == 200 => (
200,
serde_json::json!({
"protocol": { "current": 1, "supported": [1] },
"server": { "version": "0.4.1", "build": { "channel": "release" } },
"min_client": "0.1.0",
"auth": { "methods": ["bearer", "device-sig-v1"] },
"capabilities": { "devices": {
"enroll_path": "/v1/devices/enroll", "code_ttl_seconds": 900,
"poll_interval_seconds": 1, "signature_window_seconds": 60
} }
}),
),
("GET", "/.well-known/recall") => (discovery, serde_json::json!({ "error": "no" })),
("GET", "/admin/stats") if operator => (200, serde_json::json!({})),
("POST", "/v1/devices/approve") if operator => (
200,
serde_json::to_value(recall_wire::Device {
id: "dev_fake".into(),
name: "jarvis".into(),
scope: "admin".into(),
..Default::default()
})
.unwrap(),
),
(_, "/admin/stats" | "/v1/devices/approve") => {
(401, serde_json::json!({ "error": "unauthorized" }))
}
("POST", "/v1/devices/enroll") => (
200,
serde_json::to_value(recall_wire::EnrollPending {
enrollment_id: "enr_fake".into(),
user_code: "WDJB-MJHT".into(),
expires_in: 900,
interval: 1,
})
.unwrap(),
),
("POST", "/v1/devices/enroll/poll") => (
200,
serde_json::to_value(recall_wire::EnrollPollResponse {
device_id: "dev_fake".into(),
scope: "admin".into(),
})
.unwrap(),
),
("GET", "/v1/devices/me") if me == 200 && seen.signed => (
200,
serde_json::to_value(recall_wire::DeviceIdentity {
device_id: "dev_x".into(),
name: "jarvis".into(),
scope: "admin".into(),
ephemeral: false,
})
.unwrap(),
),
("GET", "/v1/devices/me") => (me, serde_json::json!({ "error": "no" })),
_ => (404, serde_json::json!({ "error": "not found" })),
}
}
fn devices_server(seen: &Seen) -> (u16, serde_json::Value) {
device_era(seen, 200, 200)
}
fn devices_server_failing_me(seen: &Seen) -> (u16, serde_json::Value) {
device_era(seen, 200, 500)
}
fn devices_server_failing_discovery(seen: &Seen) -> (u16, serde_json::Value) {
device_era(seen, 500, 200)
}
fn devices_server_without_discovery(seen: &Seen) -> (u16, serde_json::Value) {
device_era(seen, 404, 200)
}
fn sent_token(fake: &FakeServer, token: &str) -> bool {
let bearer = format!("Bearer {token}");
fake.seen()
.iter()
.any(|s| s.authorization.as_deref() == Some(bearer.as_str()))
}
#[test]
fn connect_approves_its_own_key_by_the_fingerprint_it_showed() {
let fake = fake_server(devices_server);
let repo = git_repo();
let home = recall_home_with(&[(&fake.url, "right")], &fake.url);
let home_str = home.path().to_string_lossy().to_string();
let r = run(
&["connect", "--yes", "--name", "jarvis"],
repo.path(),
&[("RECALL_HOME", &home_str)],
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
let saved = saved_device(home.path(), &fake.url).expect("the key is saved");
let fingerprint = recall_hooks::device::DeviceKey::from_saved(&saved.private_key)
.unwrap()
.fingerprint();
let seen = fake.seen();
let approve = seen
.iter()
.find(|s| s.path == "/v1/devices/approve")
.expect("it approved itself");
assert_eq!(approve.authorization.as_deref(), Some("Bearer right"));
let sent: recall_wire::ApproveRequest = serde_json::from_str(&approve.body).unwrap();
assert_eq!(sent.user_code, "WDJB-MJHT");
assert_eq!(sent.scope, "admin");
assert_eq!(sent.fingerprint.as_deref(), Some(fingerprint.as_str()));
assert!(
r.stderr.contains(&format!("Fingerprint {fingerprint}")),
"the one it showed: {}",
r.stderr
);
}
#[test]
fn connect_never_sends_one_servers_token_to_another() {
let other = fake_server(devices_server);
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let r = run(
&["connect", &other.url, "--yes", "--name", "jarvis"],
repo.path(),
&[
("RECALL_HOME", &home_str),
("RECALL_URL", "https://a.example.com"),
("RECALL_TOKEN", "token-for-a"),
],
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(!sent_token(&other, "token-for-a"), "{:?}", other.seen());
assert!(
other
.seen()
.iter()
.any(|s| s.path == "/v1/devices/enroll/poll"),
"approved elsewhere: {:?}",
other.seen()
);
assert!(saved_device(home.path(), &other.url).is_some());
}
#[test]
fn connect_removes_only_this_servers_token() {
let server = live_server("right");
let repo = git_repo();
let elsewhere = "https://other.example.com";
let home = recall_home_with(
&[(&server.url, "right"), (elsewhere, "other-token")],
&server.url,
);
let home_str = home.path().to_string_lossy().to_string();
let r = run(
&["connect", "--yes", "--name", "jarvis"],
repo.path(),
&[("RECALL_HOME", &home_str)],
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
let creds = recall_hooks::home::Home::at(home.path())
.load_credentials()
.unwrap()
.expect("another server's token is still kept");
assert_eq!(creds.token_for(&server.url), None);
assert_eq!(creds.token_for(elsewhere), Some("other-token"));
}
#[test]
fn connect_keeps_a_device_key_it_could_not_check() {
let fake = fake_server(devices_server_failing_me);
let repo = git_repo();
let home = recall_home_with(&[], &fake.url);
let home_str = home.path().to_string_lossy().to_string();
let entry = made_up_device("dev_x", "jarvis", false);
recall_hooks::home::Home::at(home.path())
.save_device(&fake.url, entry.clone())
.unwrap();
let r = run(
&["connect", "--yes"],
repo.path(),
&[("RECALL_HOME", &home_str)],
None,
);
assert_eq!(r.code, 1, "stderr: {}", r.stderr);
assert!(
r.stderr
.contains("could not check this machine's device key"),
"stderr: {}",
r.stderr
);
assert_eq!(saved_device(home.path(), &fake.url).as_ref(), Some(&entry));
assert!(
fake.seen()
.iter()
.all(|s| !s.path.starts_with("/v1/devices/enroll")),
"nothing enrolled: {:?}",
fake.seen()
);
}
#[test]
fn connect_with_a_device_key_never_falls_back_to_the_token() {
let repo = git_repo();
let entry = made_up_device("dev_x", "jarvis", false);
let failing = fake_server(devices_server_failing_discovery);
let home = recall_home_with(&[(&failing.url, "right")], &failing.url);
let home_str = home.path().to_string_lossy().to_string();
recall_hooks::home::Home::at(home.path())
.save_device(&failing.url, entry.clone())
.unwrap();
let creds = std::fs::read_to_string(home.path().join("credentials.toml")).unwrap();
let r = run(
&["connect", "--yes"],
repo.path(),
&[("RECALL_HOME", &home_str)],
None,
);
assert_eq!(r.code, 1, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("/.well-known/recall"),
"stderr: {}",
r.stderr
);
assert!(!sent_token(&failing, "right"), "{:?}", failing.seen());
assert_eq!(
std::fs::read_to_string(home.path().join("credentials.toml")).unwrap(),
creds
);
assert_eq!(
saved_device(home.path(), &failing.url).as_ref(),
Some(&entry)
);
let missing = fake_server(devices_server_without_discovery);
let home = recall_home_with(&[(&missing.url, "right")], &missing.url);
let home_str = home.path().to_string_lossy().to_string();
recall_hooks::home::Home::at(home.path())
.save_device(&missing.url, entry.clone())
.unwrap();
let r = run(
&["connect", "--yes"],
repo.path(),
&[("RECALL_HOME", &home_str)],
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("Enrolled as jarvis (admin)"),
"stderr: {}",
r.stderr
);
assert!(!sent_token(&missing, "right"), "{:?}", missing.seen());
assert!(
missing
.seen()
.iter()
.any(|s| s.path == "/v1/devices/me" && s.signed),
"{:?}",
missing.seen()
);
let saved = recall_hooks::home::Home::at(home.path())
.load_credentials()
.unwrap()
.and_then(|c| c.token_for(&missing.url).map(str::to_string));
assert_eq!(saved, None, "the token is not saved again");
}
#[test]
fn connect_refuses_when_the_device_key_file_cannot_be_read() {
let fake = fake_server(devices_server);
let repo = git_repo();
let home = recall_home_with(&[(&fake.url, "right")], &fake.url);
let home_str = home.path().to_string_lossy().to_string();
std::fs::write(home.path().join("device.key"), "servers = [").unwrap();
let r = run(
&["connect", "--yes", "--name", "jarvis"],
repo.path(),
&[("RECALL_HOME", &home_str)],
None,
);
assert_eq!(r.code, 1, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("device.key") && r.stderr.contains("Nothing was changed"),
"stderr: {}",
r.stderr
);
assert!(fake.seen().is_empty(), "{:?}", fake.seen());
assert_eq!(
std::fs::read_to_string(home.path().join("device.key")).unwrap(),
"servers = ["
);
}
#[test]
fn against_a_server_without_devices_everything_stays_on_the_token() {
let fake = fake_server(release_before_devices);
let repo = git_repo();
let home = recall_home_with(&[(&fake.url, "right")], &fake.url);
let home_str = home.path().to_string_lossy().to_string();
let r = run(
&["connect", "--yes", "--name", "jarvis"],
repo.path(),
&[("RECALL_HOME", &home_str)],
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(r.stderr.contains("Saved token OK"), "stderr: {}", r.stderr);
assert!(!r.stderr.contains("Fingerprint"), "stderr: {}", r.stderr);
assert!(!home.path().join("device.key").exists());
let creds = std::fs::read_to_string(home.path().join("credentials.toml")).unwrap();
assert!(creds.contains("token = \"right\""), "{creds}");
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_AUTHKEY", "recall-ak-notfromthisserver"),
];
let r = push_memory(&repo, &env, "fact.md", "A fact.\n");
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("this server does not enrol devices")
&& r.stderr.contains("using RECALL_TOKEN instead"),
"stderr: {}",
r.stderr
);
assert!(!home.path().join("device.key").exists());
let rep = status_json(repo.path(), &[("RECALL_HOME", home_str.as_str())]);
assert_eq!(rep["auth"], "bearer", "{rep}");
assert_eq!(rep["server_devices"], false, "{rep}");
let finding = doctor_finding(repo.path(), &[("RECALL_HOME", home_str.as_str())], "device");
assert_eq!(finding["level"], "ok", "{finding}");
let seen = fake.seen();
let synced: Vec<&Seen> = seen.iter().filter(|s| s.path == "/sync").collect();
assert!(!synced.is_empty(), "{seen:?}");
for s in &synced {
assert_eq!(s.authorization.as_deref(), Some("Bearer right"), "{s:?}");
assert!(!s.signed, "{s:?}");
}
assert!(seen.iter().all(|s| !s.signed), "{seen:?}");
}
#[test]
fn a_signed_request_refused_just_after_a_server_start_is_signed_again() {
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let server = live_server_just_started("right");
let key = block_on(
operator(&server).create_authkey(&recall_wire::AuthkeyRequest {
tag: "cloud".into(),
expires_in_days: 1,
ephemeral: true,
max_devices: None,
}),
)
.unwrap();
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_URL", server.url.as_str()),
("RECALL_AUTHKEY", key.key.as_str()),
];
let started = std::time::Instant::now();
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stderr.contains("enrolled this session"),
"stderr: {}",
r.stderr
);
assert!(
!r.stderr.contains("leaving local memory untouched"),
"the pull went through: {}",
r.stderr
);
assert!(
started.elapsed() >= std::time::Duration::from_secs(2),
"a signature this soon after the start is refused, so it waited and signed again"
);
}
fn witnessed(home: &Path, server: &LiveServer) -> recall_hooks::audit::Saved {
recall_hooks::audit::Witness::new(home.join("audit.json"), &server.url)
.load()
.unwrap()
}
fn audit_verify_py(file: &Path, args: &[String]) -> (i32, String) {
let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/audit-verify.py");
let out = Command::new("python3")
.arg(script)
.arg(file)
.arg("--ed25519=builtin")
.args(args)
.output()
.expect("python3 must be on PATH");
(
out.status.code().unwrap_or(-1),
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
),
)
}
fn rewrite_leaf(db: &Path, from: &str, to: &str) {
let conn = rusqlite::Connection::open(db).unwrap();
conn.execute_batch("DROP TRIGGER audit_log_no_update")
.unwrap();
let leaves: Vec<(i64, Vec<u8>)> = conn
.prepare("SELECT seq, leaf FROM audit_log ORDER BY seq")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.map(Result::unwrap)
.collect();
let (seq, leaf) = leaves
.into_iter()
.find(|(_, l)| String::from_utf8_lossy(l).contains(from))
.unwrap_or_else(|| panic!("no leaf holds {from}"));
let forged = String::from_utf8(leaf).unwrap().replacen(from, to, 1);
let hash = recall_wire::audit::merkle::hash_leaf(forged.as_bytes());
conn.execute(
"UPDATE audit_log SET leaf = ?1, leaf_hash = ?2 WHERE seq = ?3",
(forged.as_bytes(), &hash[..], seq),
)
.unwrap();
}
#[test]
fn an_export_verifies_here_and_with_the_script_and_tampering_does_not() {
let server = live_server("right");
let repo = git_repo();
let home = enrolled(&server, &repo, "laptop");
let home_str = home.path().to_string_lossy().to_string();
let env = [("RECALL_HOME", home_str.as_str())];
let r = push_memory(&repo, &env, "fact.md", "A fact.\n");
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
let saved = witnessed(home.path(), &server);
assert!(
!saved.unchecked.is_empty(),
"every pull saves the checkpoint it carries: {saved:?}"
);
let file = home.path().join("audit.jsonl");
let file_str = file.to_string_lossy().to_string();
let r = run(
&["audit", "export", "-o", &file_str],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(r.stderr.contains("wrote"), "stderr: {}", r.stderr);
assert!(
r.stderr.contains("it extends the"),
"the export held the saved checkpoints to its leaves: {}",
r.stderr
);
let saved = witnessed(home.path(), &server);
assert!(saved.unchecked.is_empty(), "now proven: {saved:?}");
let r = run(&["audit", "verify", &file_str], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(r.stdout.starts_with("recall audit verify"), "{}", r.stdout);
assert!(r.stdout.contains("every one checked"), "{}", r.stdout);
assert!(
r.stdout.contains("saved here for 127.0.0.1:"),
"{}",
r.stdout
);
assert!(
r.stdout.trim_end().ends_with("✓ The export checks out."),
"{}",
r.stdout
);
let checkpoints: Vec<String> = saved
.all()
.iter()
.map(|c| format!("--checkpoint={}", c.header().replacen(' ', ":", 1)))
.collect();
let (code, out) = audit_verify_py(&file, &checkpoints);
assert_eq!(code, 0, "{out}");
let export = std::fs::read_to_string(&file).unwrap();
let lines: Vec<&str> = export.lines().collect();
let mut dropped = lines.clone();
dropped.remove(3);
let mut swapped = lines.clone();
swapped.swap(2, 3);
for (what, forged) in [
("a changed byte", export.replacen("fact.md", "fakt.md", 1)),
("a missing leaf", dropped.join("\n") + "\n"),
("two swapped leaves", swapped.join("\n") + "\n"),
] {
let path = home.path().join("forged.jsonl");
std::fs::write(&path, forged).unwrap();
let path_str = path.to_string_lossy().to_string();
let r = run(&["audit", "verify", &path_str], repo.path(), &env, None);
assert_eq!(r.code, 1, "{what} was accepted: {}", r.stdout);
assert!(
r.stderr.contains("✗ The export does not check out"),
"{what}: {}",
r.stderr
);
for cp in saved.all() {
let root = cp.header().split_once(' ').unwrap().1.to_string();
assert!(!r.stderr.contains(&root), "{what}: {}", r.stderr);
}
let (code, out) = audit_verify_py(&path, &checkpoints);
assert_eq!(code, 1, "{what}, the script: {out}");
}
let r = run(&["audit", "verify"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stdout.contains("extends every checkpoint saved here"),
"{}",
r.stdout
);
}
#[test]
fn a_file_the_script_accepts_is_accepted_here() {
let dir = tempfile::tempdir().unwrap();
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../recall-wire/fixtures/wire/0.4.2/audit_entries_response.json");
let page: recall_wire::AuditEntriesResponse =
serde_json::from_slice(&std::fs::read(fixture).unwrap()).unwrap();
let checkpoint: recall_wire::AuditCheckpoint = serde_json::from_slice(
&std::fs::read(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../recall-wire/fixtures/wire/0.4.2/audit_checkpoint_response.json"),
)
.unwrap(),
)
.unwrap();
let mut export = format!("{}\n", checkpoint.to_header_value());
for leaf in &page.entries {
export.push_str(leaf);
export.push('\n');
}
let file = dir.path().join("captured.jsonl");
std::fs::write(&file, &export).unwrap();
let file_str = file.to_string_lossy().to_string();
let (code, out) = audit_verify_py(&file, &[]);
assert_eq!(code, 0, "{out}");
let r = run(&["audit", "verify", &file_str], dir.path(), &[], None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(r.stdout.contains("leaves 10,"), "{}", r.stdout);
assert!(r.stdout.contains("on 1 signed leaf"), "{}", r.stdout);
assert!(
r.stdout.contains("none saved here or given"),
"{}",
r.stdout
);
let early = format!(
"--checkpoint={}",
checkpoint.to_header_value().replacen(' ', ":", 1)
);
let r = run(
&["audit", "verify", &file_str, &early],
dir.path(),
&[],
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
let push = page
.entries
.iter()
.find(|l| l.contains("\"signature\":\"x4bs"))
.unwrap();
let forged = export.replacen(
push,
&push.replacen("\"signature\":\"x4bs", "\"signature\":\"y4bs", 1),
1,
);
std::fs::write(&file, forged).unwrap();
let (code, out) = audit_verify_py(&file, &[]);
assert_eq!(code, 1, "{out}");
let r = run(&["audit", "verify", &file_str], dir.path(), &[], None);
assert_eq!(r.code, 1, "stdout: {}", r.stdout);
assert!(r.stderr.contains("does not verify"), "stderr: {}", r.stderr);
let r = run(&["audit", "verify", "no-such.jsonl"], dir.path(), &[], None);
assert_eq!(r.code, 2, "stderr: {}", r.stderr);
let r = run(
&["audit", "verify", &file_str, "--checkpoint", "12:nope"],
dir.path(),
&[],
None,
);
assert_eq!(r.code, 2, "stderr: {}", r.stderr);
}
#[test]
fn a_server_that_rewrote_its_history_is_caught_and_stays_caught() {
let server = live_server("right");
let repo = git_repo();
let home = enrolled(&server, &repo, "laptop");
let home_str = home.path().to_string_lossy().to_string();
let env = [("RECALL_HOME", home_str.as_str())];
let r = push_memory(&repo, &env, "fact.md", "A fact.\n");
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
let before = doctor_finding(repo.path(), &env, "audit log");
assert_eq!(before["level"], "ok", "{before}");
assert!(
before["detail"]
.as_str()
.unwrap()
.contains("extends every checkpoint saved here"),
"{before}"
);
let server = server.restart_after(|db| {
rewrite_leaf(db, "\"file_path\":\"fact.md\"", "\"file_path\":\"fake.md\"")
});
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0, "a hook never fails for this: {}", r.stderr);
let r = run(&["doctor", "--json"], repo.path(), &env, None);
assert_ne!(r.code, 0, "doctor fails on it: {}", r.stdout);
let found = doctor_finding(repo.path(), &env, "audit log");
assert_eq!(found["level"], "fail", "{found}");
assert!(
found["detail"]
.as_str()
.unwrap()
.contains("no longer extends"),
"{found}"
);
assert!(
found["fix"]
.as_str()
.unwrap()
.contains("recall audit reset"),
"{found}"
);
let status = status_json(repo.path(), &env);
assert!(
status["audit"]["inconsistent"]["detail"].is_string(),
"status shows it: {}",
status["audit"]
);
assert_eq!(status["audit"]["extends"], false);
let r = run(&["status"], repo.path(), &env, None);
assert!(
r.stdout.contains("✗ audit log")
&& words(&r.stdout).contains("rewritten: the server's log no longer extends"),
"{}",
r.stdout
);
assert!(
fix_after(&r.stdout, "✗ audit log").is_some()
&& words(&r.stdout).contains("recall audit reset"),
"{}",
r.stdout
);
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stderr
.contains("WARNING: the server's audit log no longer extends a checkpoint"),
"every session start says so: {}",
r.stderr
);
let r = run(&["audit", "verify"], repo.path(), &env, None);
assert_eq!(r.code, 1, "stdout: {}", r.stdout);
assert!(
r.stderr
.contains("✗ the server's audit log no longer extends a checkpoint"),
"{}",
r.stderr
);
assert!(
r.stderr.contains("→ recall audit reset")
&& r.stderr
.contains("→ recall audit export -o audit-evidence.jsonl"),
"{}",
r.stderr
);
assert!(witnessed(home.path(), &server).inconsistent.is_some());
let r = run(&["audit", "reset"], repo.path(), &env, None);
assert_ne!(r.code, 0, "not without --yes or a terminal: {}", r.stderr);
assert!(witnessed(home.path(), &server).inconsistent.is_some());
let r = run(&["audit", "reset", "--yes"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert!(
r.stdout.contains("Forgot") && r.stdout.contains("and the rewrite found"),
"{}",
r.stdout
);
let after = doctor_finding(repo.path(), &env, "audit log");
assert_eq!(after["level"], "ok", "{after}");
}
#[test]
fn an_export_needs_an_admin_and_says_so() {
let server = live_server("right");
let repo = git_repo();
let key = ephemeral_authkey(&server);
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_URL", server.url.as_str()),
("RECALL_AUTHKEY", key.as_str()),
];
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
let r = run(&["audit", "export"], repo.path(), &env, None);
assert_eq!(r.code, 2, "stderr: {}", r.stderr);
assert!(r.stdout.is_empty(), "nothing half-written: {}", r.stdout);
assert!(
r.stderr
.contains("needs an admin device or the server's RECALL_TOKEN")
&& r.stderr.contains("a sync device"),
"stderr: {}",
r.stderr
);
let r = run(&["audit", "verify"], repo.path(), &env, None);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
}
const A_ROOT: &str = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=";
fn saw_checkpoints(home: &Path, url: &str, sizes: std::ops::RangeInclusive<u64>) {
let witness = recall_hooks::audit::Witness::new(home.join("audit.json"), url);
for size in sizes {
witness.record(&format!("{size} {A_ROOT}")).unwrap();
}
}
fn discovery_down(seen: &Seen) -> (u16, serde_json::Value) {
match seen.path.as_str() {
"/health" => (
200,
serde_json::to_value(recall_wire::Health {
status: "ok".into(),
..Default::default()
})
.unwrap(),
),
"/.well-known/recall" => (500, serde_json::json!({ "error": "boom" })),
"/sync" => (
200,
serde_json::json!({ "project_key": "acme/app", "files": [] }),
),
"/v1/audit/checkpoint" => (
200,
serde_json::json!({ "tree_size": 5, "root_hash": A_ROOT }),
),
_ => (404, serde_json::json!({ "error": "not found" })),
}
}
#[test]
fn saved_checkpoints_are_checked_when_discovery_fails() {
let server = fake_server(discovery_down);
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
saw_checkpoints(home.path(), &server.url, 5..=5);
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_URL", server.url.as_str()),
("RECALL_TOKEN", "right"),
];
let status = status_json(repo.path(), &env);
assert_eq!(status["audit"]["extends"], true, "{}", status["audit"]);
assert!(
server
.seen()
.iter()
.any(|s| s.path == "/v1/audit/checkpoint"),
"the server was asked"
);
}
#[test]
fn a_log_that_went_away_fails_verify_and_one_never_kept_cannot_be_checked() {
let server = fake_server(|_| (404, serde_json::json!({ "error": "not found" })));
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_URL", server.url.as_str()),
("RECALL_TOKEN", "right"),
];
let r = run(&["audit", "verify"], repo.path(), &env, None);
assert_eq!(r.code, 2, "never kept: {}", r.stderr);
saw_checkpoints(home.path(), &server.url, 5..=5);
let r = run(&["audit", "verify"], repo.path(), &env, None);
assert_eq!(r.code, 1, "went away: {}", r.stderr);
assert!(r.stderr.contains("keeps no audit log"), "{}", r.stderr);
}
#[test]
fn an_export_of_a_log_that_went_away_fails() {
let server = fake_server(|_| (404, serde_json::json!({ "error": "not found" })));
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_URL", server.url.as_str()),
("RECALL_TOKEN", "right"),
];
let r = run(&["audit", "export"], repo.path(), &env, None);
assert_eq!(r.code, 2, "never kept: {}", r.stderr);
saw_checkpoints(home.path(), &server.url, 5..=5);
let r = run(&["audit", "export"], repo.path(), &env, None);
assert_eq!(r.code, 1, "went away: {}", r.stderr);
assert!(r.stderr.contains("keeps no audit log"), "{}", r.stderr);
}
#[test]
fn a_refused_credential_says_what_to_do_about_it() {
let server = fake_server(|seen| match seen.path.as_str() {
"/v1/audit/checkpoint" => (403, serde_json::json!({ "error": "forbidden" })),
_ => (404, serde_json::json!({ "error": "not found" })),
});
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_URL", server.url.as_str()),
("RECALL_TOKEN", "right"),
];
saw_checkpoints(home.path(), &server.url, 5..=5);
let r = run(&["audit", "verify"], repo.path(), &env, None);
assert_eq!(r.code, 2, "{}", r.stderr);
assert!(
r.stderr.contains("refused this machine's credential"),
"{}",
r.stderr
);
assert!(r.stderr.contains("recall connect"), "{}", r.stderr);
}
#[test]
fn reset_cannot_forget_what_it_cannot_read() {
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let file = home.path().join("audit.json");
std::fs::write(&file, "{ not json").unwrap();
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_URL", DEAD_SERVER),
];
let r = run(&["audit", "reset", "--yes"], repo.path(), &env, None);
assert_eq!(r.code, 2, "{}", r.stderr);
assert_eq!(std::fs::read(&file).unwrap(), b"{ not json");
}
#[test]
fn not_set_up_to_ask_cannot_be_checked() {
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let r = run(
&["audit", "verify"],
repo.path(),
&[("RECALL_HOME", &home_str)],
None,
);
assert_eq!(r.code, 2, "no server: {}", r.stderr);
let r = run(
&["audit", "export"],
repo.path(),
&[("RECALL_HOME", &home_str), ("RECALL_URL", DEAD_SERVER)],
None,
);
assert_eq!(r.code, 2, "no credential: {}", r.stderr);
}
#[test]
fn the_pull_hook_nudges_and_warns_about_the_audit_record() {
let repo = git_repo();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().to_string();
let env = [
("RECALL_HOME", home_str.as_str()),
("RECALL_URL", DEAD_SERVER),
("RECALL_TOKEN", "right"),
];
saw_checkpoints(home.path(), DEAD_SERVER, 1..=16);
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0);
assert!(!r.stderr.contains("wait to be checked"), "{}", r.stderr);
saw_checkpoints(home.path(), DEAD_SERVER, 17..=17);
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0);
assert!(
r.stderr
.contains("17 audit checkpoints wait to be checked; recall doctor checks them"),
"{}",
r.stderr
);
std::fs::write(home.path().join("audit.json"), "{ not json").unwrap();
let r = run(&["pull"], repo.path(), &env, None);
assert_eq!(r.code, 0);
assert!(
r.stderr.contains("may hold the only record of a rewrite"),
"{}",
r.stderr
);
let found = doctor_finding(repo.path(), &env, "audit log");
assert_eq!(found["level"], "fail", "{found}");
}
#[test]
fn an_export_that_cannot_check_the_saved_checkpoints_does_not_pass() {
let server = live_server("right");
let repo = git_repo();
let home = recall_home_with(&[(&server.url, "right")], &server.url);
let home_str = home.path().to_string_lossy().to_string();
let env = [("RECALL_HOME", home_str.as_str())];
std::fs::write(home.path().join("audit.json"), "{ not json").unwrap();
let out = home.path().join("audit.jsonl");
let out_str = out.to_string_lossy().to_string();
let r = run(
&["audit", "export", "-o", &out_str],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 2, "stderr: {}", r.stderr);
assert!(r.stderr.contains("could not be checked"), "{}", r.stderr);
assert_eq!(
std::fs::read_to_string(home.path().join("audit.json")).unwrap(),
"{ not json"
);
}
#[cfg(unix)]
#[test]
fn an_export_never_writes_through_a_link_in_its_way() {
let server = live_server("right");
let repo = git_repo();
let home = recall_home_with(&[(&server.url, "right")], &server.url);
let home_str = home.path().to_string_lossy().to_string();
let env = [("RECALL_HOME", home_str.as_str())];
let victim = home.path().join("precious.txt");
std::fs::write(&victim, "precious").unwrap();
let out = home.path().join("audit.jsonl");
std::os::unix::fs::symlink(&victim, home.path().join("audit.jsonl.partial")).unwrap();
let out_str = out.to_string_lossy().to_string();
let r = run(
&["audit", "export", "-o", &out_str],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "stderr: {}", r.stderr);
assert_eq!(std::fs::read_to_string(&victim).unwrap(), "precious");
let written = std::fs::read_to_string(&out).unwrap();
assert!(written.split_once(' ').is_some(), "{written}");
assert!(!home.path().join("audit.jsonl.partial").exists());
}
fn plant_report(server: &LiveServer, project_key: &str, content: &str) {
let edit = serde_json::json!({"project_key": project_key, "file_path": "deploy.md",
"base_sha256": recall_wire::content_sha256(content),
"lines": [2, 2], "replacement": "- key: [removed]\n"});
let findings = serde_json::json!([{"id": "f1", "kind": "secret", "severity": "high",
"project_key": project_key, "file_path": "deploy.md",
"lines": [2, 2], "related": []}]);
let details = serde_json::json!({"findings": {"f1": {"excerpt": "- key: abc1… (masked)\n",
"reasoning": "A key is kept in memory.",
"suggested_edit": edit}},
"skipped": []});
let conn = rusqlite::Connection::open(&server.cfg.db_path).unwrap();
conn.execute(
"INSERT INTO evaluations (id, state, job_id, projects, contradictions, findings, details,
created_at, finished_at)
VALUES ('eval_cli', 'done', 'job_cli', '[]', 0, ?1, ?2,
'2026-09-25T10:00:00.000Z', '2026-09-25T10:01:00.000Z')",
(findings.to_string(), details.to_string()),
)
.unwrap();
}
#[test]
fn eval_apply_makes_the_suggested_edit_and_pushes_it() {
let server = live_server("right");
let repo = git_repo();
let home = recall_home_with(&[(&server.url, "right")], &server.url);
let home_str = home.path().to_string_lossy().to_string();
let env = [("RECALL_HOME", home_str.as_str())];
let content = "# Deploy\n- key: abc123\n- ship it\n";
let pushed = push_memory(&repo, &env, "deploy.md", content);
assert_eq!(pushed.code, 0, "{}", pushed.stderr);
let key = status_json(repo.path(), &env)["project_key"]
.as_str()
.unwrap()
.to_string();
plant_report(&server, &key, content);
let list = run(&["eval", "list"], repo.path(), &env, None);
assert_eq!(list.code, 0, "{}", list.stderr);
assert!(
list.stdout.contains("eval_cli") && list.stdout.contains("1 secret"),
"{}",
list.stdout
);
assert!(
list.stdout.contains("! eval_cli") && list.stdout.contains("→ recall eval show eval_cli"),
"{}",
list.stdout
);
let show = run(&["eval", "show", "eval_cli"], repo.path(), &env, None);
assert_eq!(show.code, 0, "{}", show.stderr);
for want in [
"✗ 1 high ! 0 medium ○ 0 low",
"✗ f1 secret deploy.md L2",
"│ - key: abc1… (masked)",
"→ recall eval apply f1 --eval eval_cli",
] {
assert!(show.stdout.contains(want), "{want:?} in {}", show.stdout);
}
assert!(
show.stdout.lines().all(|l| l == l.trim_end()),
"{}",
show.stdout
);
let applied = run(&["eval", "apply", "f1", "--yes"], repo.path(), &env, None);
assert_eq!(applied.code, 0, "{}", applied.stderr);
let memory_dir = status_json(repo.path(), &env)["memory_dir"]
.as_str()
.unwrap()
.to_string();
let fixed = "# Deploy\n- key: [removed]\n- ship it\n";
assert_eq!(
std::fs::read_to_string(Path::new(&memory_dir).join("deploy.md")).unwrap(),
fixed
);
let on_server = stored(&server, &repo, &env);
let file = on_server
.iter()
.find(|f| f.file_path == "deploy.md")
.unwrap();
assert_eq!(file.content.as_deref(), Some(fixed));
let again = run(&["eval", "apply", "f1", "--yes"], repo.path(), &env, None);
assert_eq!(again.code, 1, "{}", again.stdout);
assert!(
again.stderr.contains("has changed since"),
"{}",
again.stderr
);
let missing = run(
&["eval", "apply", "f9", "--eval", "eval_cli", "--yes"],
repo.path(),
&env,
None,
);
assert_eq!(missing.code, 1);
assert!(
missing.stderr.contains("has no finding f9"),
"{}",
missing.stderr
);
let asked = run(&["eval", "run"], repo.path(), &env, None);
assert_eq!(asked.code, 2, "{}", asked.stdout);
assert!(
asked.stderr.contains("no worker is enrolled"),
"{}",
asked.stderr
);
}
fn review_repo() -> Repo {
let repo = git_repo();
let git = |args: &[&str]| {
assert!(Command::new("git")
.args(args)
.current_dir(repo.path())
.status()
.unwrap()
.success());
};
git(&["config", "user.email", "t@example.com"]);
git(&["config", "user.name", "Test"]);
std::fs::create_dir_all(repo.path().join("hooks")).unwrap();
std::fs::write(repo.path().join("hooks").join("lib.sh"), "echo hi\n").unwrap();
std::fs::write(repo.path().join("hooks").join("recall-pull"), "echo pull\n").unwrap();
std::fs::create_dir_all(repo.path().join("docs")).unwrap();
std::fs::write(repo.path().join("docs").join("plan.md"), "# Plan\n").unwrap();
git(&["add", "-A"]);
git(&[
"commit",
"-q",
"-m",
"add hooks/lib.sh and hooks/recall-pull",
]);
git(&["rm", "-q", "hooks/lib.sh"]);
git(&["rm", "-q", "hooks/recall-pull"]);
git(&["commit", "-q", "-m", "remove the old hooks (Rust rewrite)"]);
repo
}
fn write_memory(repo: &Path, env: &[(&str, &str)], rel: &str, body: &str) -> String {
let memory_dir = status_json(repo, env)["memory_dir"]
.as_str()
.unwrap()
.to_string();
let path = Path::new(&memory_dir).join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, body).unwrap();
memory_dir
}
fn claim_containing<'a>(claims: &'a serde_json::Value, needle: &str) -> &'a serde_json::Value {
claims
.as_array()
.unwrap_or_else(|| panic!("claims[] is not an array: {claims}"))
.iter()
.find(|c| c["text"].as_str().is_some_and(|t| t.contains(needle)))
.unwrap_or_else(|| panic!("no claim contains {needle:?}: {claims}"))
}
#[test]
fn the_phase1_deploy_fixture_gets_the_documented_verdicts() {
let repo = review_repo();
let env: Vec<(&str, &str)> = vec![];
write_memory(
repo.path(),
&env,
"project_phase1_deploy.md",
"---\nname: project-phase1-deploy\n---\n\n\
- Recall's server is live at `recall.pimlabs.id`, deployed via OrbStack and a Cloudflare Tunnel.\n\
- Run `lib.sh` to start the legacy hooks; `hooks/recall-pull` runs at session start.\n\
\n\
## History\n\
\n\
- Not the old `hooks/recall-pull` script, and not `lib.sh`: both were deleted in the Rust rewrite.\n",
);
let r = run(&["review", "run", "--json"], repo.path(), &env, None);
assert_eq!(
r.code, 0,
"review must exit 0 whenever it ran: {}",
r.stderr
);
let report: serde_json::Value =
serde_json::from_str(&r.stdout).unwrap_or_else(|e| panic!("not JSON ({e}): {}", r.stdout));
let claims = &report["claims"];
let hooks = claim_containing(claims, "Run `lib.sh`");
assert_eq!(hooks["class"], "present");
assert_eq!(hooks["verdict"], "stale", "{hooks}");
let evidence: Vec<&str> = hooks["evidence"]
.as_array()
.unwrap()
.iter()
.map(|e| e["detail"].as_str().unwrap())
.collect();
assert!(
evidence
.iter()
.any(|d| d.contains("lib.sh") && d.contains("deleted")),
"{evidence:?}"
);
assert!(
evidence
.iter()
.any(|d| d.contains("hooks/recall-pull") && d.contains("deleted")),
"{evidence:?}"
);
let hostname = claim_containing(claims, "is live at");
assert_eq!(hostname["class"], "present");
assert_eq!(hostname["verdict"], "cant_tell", "{hostname}");
let history = claim_containing(claims, "Not the old");
assert_eq!(history["class"], "record");
assert!(
history.get("verdict").is_none() || history["verdict"].is_null(),
"a record must never carry a verdict: {history}"
);
let text = run(&["review", "run"], repo.path(), &env, None);
assert_eq!(text.code, 0);
assert!(text.stdout.contains("stale"), "{}", text.stdout);
assert!(
text.stdout.contains("· 1 record(s)"),
"records are counted, not listed: {}",
text.stdout
);
assert!(
!text.stdout.contains(" 0 record(s)") && !text.stdout.contains(" 0 not decided"),
"{}",
text.stdout
);
assert!(text.stdout.contains("→ edit "), "{}", text.stdout);
assert!(
!text.stdout.contains("stale") || !text.stdout.contains("Not the old"),
"{}",
text.stdout
);
}
#[test]
fn a_still_true_claim_appears_in_both_outputs() {
let repo = review_repo();
assert!(Command::new("git")
.args(["tag", "v0.1.0"])
.current_dir(repo.path())
.status()
.unwrap()
.success());
let env: Vec<(&str, &str)> = vec![];
write_memory(
repo.path(),
&env,
"plan.md",
"- Recall is at version `0.1.0`; the plan lives in `docs/plan.md`.\n",
);
let json = run(&["review", "run", "--json"], repo.path(), &env, None);
assert_eq!(json.code, 0, "{}", json.stderr);
let report: serde_json::Value = serde_json::from_str(&json.stdout).unwrap();
let claim = claim_containing(&report["claims"], "docs/plan.md");
assert_eq!(claim["verdict"], "still_true", "{claim}");
let text = run(&["review", "show"], repo.path(), &env, None);
assert_eq!(text.code, 0);
assert!(text.stdout.contains("✓ 1 still true"), "{}", text.stdout);
assert!(text.stdout.contains("docs/plan.md"), "{}", text.stdout);
}
#[test]
fn the_files_argument_restricts_which_files_are_reviewed() {
let repo = review_repo();
let env: Vec<(&str, &str)> = vec![];
write_memory(
repo.path(),
&env,
"a.md",
"- The plan lives in `docs/plan.md`.\n",
);
write_memory(repo.path(), &env, "b.md", "- Run `lib.sh` to start it.\n");
let r = run(
&["review", "run", "a.md", "--json"],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let files: std::collections::HashSet<&str> = report["claims"]
.as_array()
.unwrap()
.iter()
.map(|c| c["file"].as_str().unwrap())
.collect();
assert_eq!(files, std::collections::HashSet::from(["a.md"]), "{report}");
}
#[test]
fn show_reprints_the_last_report() {
let repo = review_repo();
let env: Vec<(&str, &str)> = vec![];
write_memory(
repo.path(),
&env,
"plan.md",
"- The plan lives in `docs/plan.md`.\n",
);
let ran = run(&["review", "run", "--json"], repo.path(), &env, None);
assert_eq!(ran.code, 0, "{}", ran.stderr);
let shown = run(&["review", "show", "--json"], repo.path(), &env, None);
assert_eq!(shown.code, 0, "{}", shown.stderr);
assert_eq!(shown.stdout, ran.stdout);
}
#[test]
fn show_before_any_run_is_a_quiet_no_op() {
let repo = git_repo();
let r = run(&["review", "show"], repo.path(), &[], None);
assert_eq!(r.code, 0, "{}", r.stderr);
assert!(r.stdout.contains("No review yet"), "{}", r.stdout);
}
#[test]
fn review_never_needs_a_server() {
let repo = review_repo();
let env: Vec<(&str, &str)> = vec![];
write_memory(
repo.path(),
&env,
"plan.md",
"- The plan lives in `docs/plan.md`.\n",
);
let r = run(&["review", "run"], repo.path(), &env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
assert!(!r.stderr.contains("RECALL_URL"), "{}", r.stderr);
}
#[test]
fn a_named_file_that_is_not_memory_errors() {
let repo = review_repo();
let env: Vec<(&str, &str)> = vec![];
write_memory(
repo.path(),
&env,
"a.md",
"- The plan lives in `docs/plan.md`.\n",
);
let r = run(&["review", "run", "nope.md"], repo.path(), &env, None);
assert_ne!(r.code, 0, "{}", r.stdout);
assert!(r.stderr.contains("nope.md"), "{}", r.stderr);
}
#[test]
fn a_restricted_run_merges_into_the_stored_report_instead_of_replacing_it() {
let repo = review_repo();
let env: Vec<(&str, &str)> = vec![];
write_memory(
repo.path(),
&env,
"a.md",
"- The plan lives in `docs/plan.md`.\n",
);
write_memory(repo.path(), &env, "b.md", "- Run `lib.sh` to start it.\n");
let full = run(&["review", "run", "--json"], repo.path(), &env, None);
assert_eq!(full.code, 0, "{}", full.stderr);
let restricted = run(
&["review", "run", "a.md", "--json"],
repo.path(),
&env,
None,
);
assert_eq!(restricted.code, 0, "{}", restricted.stderr);
let report: serde_json::Value = serde_json::from_str(&restricted.stdout).unwrap();
let files: std::collections::HashSet<&str> = report["claims"]
.as_array()
.unwrap()
.iter()
.map(|c| c["file"].as_str().unwrap())
.collect();
assert_eq!(
files,
std::collections::HashSet::from(["a.md", "b.md"]),
"b.md's claim from the earlier run must survive a run restricted to a.md: {report}"
);
let shown = run(&["review", "show", "--json"], repo.path(), &env, None);
let shown_report: serde_json::Value = serde_json::from_str(&shown.stdout).unwrap();
assert_eq!(
shown_report["claims"].as_array().unwrap().len(),
report["claims"].as_array().unwrap().len()
);
}
#[test]
fn the_corrected_note_gets_no_stale_claim() {
let repo = review_repo();
let env: Vec<(&str, &str)> = vec![];
write_memory(
repo.path(),
&env,
"project_phase1_deploy.md",
"---\nname: project-phase1-deploy\n---\n\n\
The server is at `recall-server.pimlabs.id`, behind Traefik on a VPS.\n\
\n\
## History\n\
\n\
- Not the old `hooks/recall-*` scripts, and not `lib.sh`; both were deleted \
in the Rust rewrite.\n",
);
let r = run(&["review", "run", "--json"], repo.path(), &env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
for claim in report["claims"].as_array().unwrap() {
assert_ne!(
claim["verdict"], "stale",
"a corrected file must get no stale claim: {claim}"
);
}
let history = claim_containing(&report["claims"], "Not the old");
assert_eq!(history["class"], "record");
}
#[test]
fn a_cloud_memory_dir_claim_is_decided_only_in_a_cloud_session() {
let repo = review_repo();
let memory = tempfile::tempdir().unwrap();
let memory_str = memory.path().to_string_lossy().to_string();
let note = format!("- CLAUDE_CODE_REMOTE_MEMORY_DIR={memory_str}\n");
let cloud = [
("CLAUDE_CODE_REMOTE", "true"),
("CLAUDE_CODE_REMOTE_MEMORY_DIR", memory_str.as_str()),
];
write_memory(repo.path(), &cloud, "env.md", ¬e);
let r = run(&["review", "run", "--json"], repo.path(), &cloud, None);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let claim = claim_containing(&report["claims"], "CLAUDE_CODE_REMOTE_MEMORY_DIR");
assert_eq!(claim["verdict"], "still_true", "{claim}");
assert_eq!(claim["evidence"][0]["source"], "environment", "{claim}");
let laptop: [(&str, &str); 0] = [];
write_memory(repo.path(), &laptop, "env.md", ¬e);
let r = run(&["review", "run", "--json"], repo.path(), &laptop, None);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let claim = claim_containing(&report["claims"], "CLAUDE_CODE_REMOTE_MEMORY_DIR");
assert_eq!(claim["verdict"], "cant_tell", "{claim}");
}
#[test]
fn a_claim_naming_the_configured_server_is_checked_against_it() {
let server = live_server("t");
let repo = review_repo();
let env = [("RECALL_URL", server.url.as_str()), ("RECALL_TOKEN", "t")];
write_memory(
repo.path(),
&env,
"server.md",
&format!("- The server is at {}.\n", server.url),
);
let r = run(&["review", "run", "--json"], repo.path(), &env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let claim = claim_containing(&report["claims"], "The server is at");
assert_eq!(claim["verdict"], "still_true", "{claim}");
assert_eq!(claim["evidence"][0]["source"], "server", "{claim}");
assert!(
report["evidence"]["server_version"].is_string(),
"{}",
report["evidence"]
);
let unavailable = report["evidence"]["unavailable"].to_string();
assert!(!unavailable.contains("\"server\""), "{unavailable}");
}
#[test]
fn an_unreachable_server_is_reported_as_unavailable() {
let repo = review_repo();
let env = [("RECALL_URL", "http://127.0.0.1:9"), ("RECALL_TOKEN", "t")];
write_memory(repo.path(), &env, "a.md", "- Run `lib.sh` first.\n");
let r = run(&["review", "run", "--json"], repo.path(), &env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let server = report["evidence"]["unavailable"]
.as_array()
.unwrap()
.iter()
.find(|u| u["source"] == "server")
.unwrap_or_else(|| panic!("{}", report["evidence"]));
assert!(
server["reason"]
.as_str()
.unwrap()
.contains("did not answer"),
"{server}"
);
}
#[test]
fn a_claim_naming_a_compose_service_is_found_in_the_compose_file() {
let repo = review_repo();
std::fs::create_dir_all(repo.path().join("deploy")).unwrap();
std::fs::write(
repo.path().join("deploy").join("docker-compose.yml"),
"services:\n cloudflared:\n image: cloudflare/cloudflared:latest\n",
)
.unwrap();
let env: [(&str, &str); 0] = [];
write_memory(
repo.path(),
&env,
"ingress.md",
"- The server is deployed via `cloudflared`.\n",
);
let r = run(&["review", "run", "--json"], repo.path(), &env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let claim = claim_containing(&report["claims"], "cloudflared");
assert_eq!(claim["verdict"], "cant_tell", "{claim}");
assert_eq!(claim["evidence"][0]["source"], "compose", "{claim}");
assert_eq!(
report["evidence"]["compose_files"],
serde_json::json!(["deploy/docker-compose.yml"])
);
}
#[test]
fn hosts_a_note_names_are_not_probed_without_the_flag() {
let repo = review_repo();
let env: [(&str, &str); 0] = [];
write_memory(
repo.path(),
&env,
"hosts.md",
"- The mirror is at `mirror.example.invalid`.\n",
);
let r = run(&["review", "run", "--json"], repo.path(), &env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
assert!(
report["evidence"]["probed"].is_null(),
"{}",
report["evidence"]
);
let unavailable = report["evidence"]["unavailable"].to_string();
assert!(unavailable.contains("--probe-hosts"), "{unavailable}");
}
#[cfg(unix)]
fn fake_claude(answer: &str) -> (tempfile::TempDir, String) {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let envelope = serde_json::json!({"type": "result", "is_error": false, "result": answer});
std::fs::write(dir.path().join("answer.json"), envelope.to_string()).unwrap();
let script = dir.path().join("claude");
std::fs::write(
&script,
format!(
"#!/bin/sh\nd='{}'\nif [ \"$1\" = auth ]; then echo '{{\"loggedIn\":true}}'; exit 0; fi\nn=$(ls \"$d\" | grep -c '^stdin-')\ncat > \"$d/stdin-$n\"\ncat \"$d/answer.json\"\n",
dir.path().display()
),
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = format!(
"{}:{}",
dir.path().display(),
std::env::var("PATH").unwrap_or_default()
);
(dir, path)
}
#[cfg(unix)]
fn claude_calls(dir: &Path) -> Vec<String> {
let mut calls: Vec<(usize, String)> = std::fs::read_dir(dir)
.unwrap()
.filter_map(|e| {
let name = e.unwrap().file_name().to_string_lossy().to_string();
let n = name.strip_prefix("stdin-")?.parse().ok()?;
Some((n, std::fs::read_to_string(dir.join(&name)).unwrap()))
})
.collect();
calls.sort();
calls.into_iter().map(|(_, s)| s).collect()
}
#[cfg(unix)]
const C1_STALE: &str = r#"{"claims":[{"id":"C1","class":"present","verdict":"stale","cites":["E1"],"reason":"The facts say otherwise."}]}"#;
#[cfg(unix)]
#[test]
fn no_claude_call_is_made_without_the_flag() {
let repo = review_repo();
let (fake, path) = fake_claude(C1_STALE);
let env = [("PATH", path.as_str())];
write_memory(
repo.path(),
&env,
"plans.md",
"- The deploy happens on Tuesdays.\n",
);
let r = run(&["review", "run", "--json"], repo.path(), &env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
assert!(claude_calls(fake.path()).is_empty(), "claude was called");
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let unavailable = report["evidence"]["unavailable"].to_string();
assert!(unavailable.contains("--claude"), "{unavailable}");
assert!(
report["evidence"]["claude"].is_null(),
"{}",
report["evidence"]
);
let r = run(
&["review", "run", "--max-calls", "3"],
repo.path(),
&env,
None,
);
assert_ne!(r.code, 0, "{}", r.stdout);
assert!(claude_calls(fake.path()).is_empty(), "claude was called");
let r = run(&["review", "run", "--claude"], repo.path(), &env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
assert_eq!(claude_calls(fake.path()).len(), 1, "the control");
}
#[cfg(unix)]
#[test]
fn a_claude_verdict_is_kept_until_the_file_changes() {
let repo = review_repo();
let (fake, path) = fake_claude(C1_STALE);
let env = [("PATH", path.as_str())];
let note = "- The deploy happens on Tuesdays.\n";
write_memory(repo.path(), &env, "plans.md", note);
let r = run(
&["review", "run", "--claude", "--json"],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let claim = claim_containing(&report["claims"], "Tuesdays");
assert_eq!(claim["class"], "present", "{claim}");
assert_eq!(claim["verdict"], "stale", "{claim}");
assert_eq!(claim["layer"], 3, "{claim}");
assert_eq!(claim["evidence"][0]["source"], "claude", "{claim}");
assert_eq!(
report["evidence"]["claude"]["calls"], 1,
"{}",
report["evidence"]
);
let calls = claude_calls(fake.path());
assert_eq!(calls.len(), 1);
assert!(
calls[0].contains("C1 (line 1): The deploy happens on Tuesdays."),
"{}",
calls[0]
);
assert!(calls[0].contains("E1: "), "{}", calls[0]);
for args in [
&["review", "run", "--claude", "--json"][..],
&["review", "run", "--json"][..],
] {
let r = run(args, repo.path(), &env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let claim = claim_containing(&report["claims"], "Tuesdays");
assert_eq!(claim["verdict"], "stale", "{args:?}: {claim}");
assert_eq!(claude_calls(fake.path()).len(), 1, "{args:?}");
}
let r = run(
&["review", "run", "--claude", "--all"],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "{}", r.stderr);
assert_eq!(claude_calls(fake.path()).len(), 2);
write_memory(
repo.path(),
&env,
"plans.md",
&format!("{note}- Nothing else.\n"),
);
let r = run(&["review", "run", "--claude"], repo.path(), &env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
assert_eq!(claude_calls(fake.path()).len(), 3);
}
#[cfg(unix)]
#[test]
fn a_planted_token_never_reaches_claude() {
let repo = review_repo();
let (fake, path) = fake_claude(C1_STALE);
let env = [("PATH", path.as_str())];
let token = format!("ghp_{}", "Zx9Yw8Vu7T".repeat(4));
write_memory(
repo.path(),
&env,
"creds.md",
&format!("- The deploy key is {token} and it opens the vault.\n"),
);
let r = run(&["review", "run", "--claude"], repo.path(), &env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
let calls = claude_calls(fake.path());
assert_eq!(calls.len(), 1);
assert!(!calls[0].contains(&token), "{}", calls[0]);
assert!(calls[0].contains("it opens the vault"), "{}", calls[0]);
}
#[cfg(unix)]
#[test]
fn max_calls_bounds_the_calls_and_lists_what_it_skipped() {
let repo = review_repo();
let (fake, path) = fake_claude(C1_STALE);
let env = [("PATH", path.as_str())];
write_memory(
repo.path(),
&env,
"a.md",
"- The deploy happens on Tuesdays.\n",
);
write_memory(
repo.path(),
&env,
"b.md",
"- The backup happens on Fridays.\n",
);
let r = run(
&["review", "run", "--claude", "--max-calls", "1", "--json"],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "{}", r.stderr);
assert_eq!(claude_calls(fake.path()).len(), 1);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let skipped = &report["evidence"]["claude"]["skipped"];
assert_eq!(skipped[0]["file"], "b.md", "{skipped}");
assert!(
skipped[0]["reason"]
.as_str()
.unwrap()
.contains("--max-calls 1"),
"{skipped}"
);
let r = run(&["review", "show"], repo.path(), &env, None);
assert!(r.stdout.contains("! b.md: --max-calls 1"), "{}", r.stdout);
}
#[cfg(unix)]
#[test]
fn a_claude_verdict_citing_nothing_is_cant_tell() {
let repo = review_repo();
let (_fake, path) = fake_claude(
r#"{"claims":[{"id":"C1","class":"present","verdict":"stale","cites":[],"reason":"I just know."}]}"#,
);
let env = [("PATH", path.as_str())];
write_memory(
repo.path(),
&env,
"plans.md",
"- The deploy happens on Tuesdays.\n",
);
let r = run(
&["review", "run", "--claude", "--json"],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let claim = claim_containing(&report["claims"], "Tuesdays");
assert_eq!(claim["verdict"], "cant_tell", "{claim}");
let detail = claim["evidence"][0]["detail"].as_str().unwrap();
assert!(
detail.starts_with("claude's reading, not evidence"),
"{detail}"
);
}
#[cfg(unix)]
#[test]
fn records_and_rules_are_never_asked_about() {
let repo = review_repo();
let (fake, path) = fake_claude(C1_STALE);
let env = [("PATH", path.as_str())];
write_memory(
repo.path(),
&env,
"plans.md",
"- The deploy happens on Tuesdays.\n- The deploy used to happen on Mondays.\n",
);
write_memory(
repo.path(),
&env,
"feedback_style.md",
"---\ntype: feedback\n---\n- Always answer in Indonesian.\n",
);
let r = run(&["review", "run", "--claude"], repo.path(), &env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
let calls = claude_calls(fake.path());
assert_eq!(calls.len(), 1, "the feedback note has nothing to ask about");
let claims = calls[0].split("=== Claims to judge ===").nth(1).unwrap();
assert!(
claims.contains("C1 (line 1): The deploy happens on Tuesdays."),
"{claims}"
);
assert!(!claims.contains("Mondays"), "{claims}");
assert!(!claims.contains("C2"), "{claims}");
}
#[cfg(unix)]
#[test]
fn an_answer_whose_fact_went_missing_is_kept_not_shown() {
let repo = review_repo();
let (fake, path) = fake_claude(
r#"{"claims":[{"id":"C1","class":"present","verdict":"stale","cites":["E1","E2","E3","E4"],"reason":"The facts say otherwise."}]}"#,
);
let unset = [("PATH", path.as_str())];
let moved = [
("PATH", path.as_str()),
("RECALL_URL", "http://127.0.0.1:9"),
];
write_memory(
repo.path(),
&unset,
"plans.md",
"- The deploy happens on Tuesdays.\n",
);
let verdict = |env: &[(&str, &str)], args: &[&str]| {
let r = run(args, repo.path(), env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
claim_containing(&report["claims"], "Tuesdays")["verdict"].clone()
};
assert_eq!(
verdict(&unset, &["review", "run", "--claude", "--json"]),
"stale"
);
assert_eq!(
verdict(&moved, &["review", "run", "--json"]),
serde_json::Value::Null
);
assert_eq!(verdict(&unset, &["review", "run", "--json"]), "stale");
assert_eq!(claude_calls(fake.path()).len(), 1);
assert_eq!(
verdict(&moved, &["review", "run", "--claude", "--json"]),
"stale"
);
assert_eq!(claude_calls(fake.path()).len(), 2);
}
#[cfg(unix)]
#[test]
fn a_partial_answer_is_shown_and_asked_again() {
let repo = review_repo();
let (fake, path) = fake_claude(C1_STALE);
let env = [("PATH", path.as_str())];
write_memory(
repo.path(),
&env,
"plans.md",
"- The deploy happens on Tuesdays.\n- The backup happens on Fridays.\n",
);
for expected in [1, 2] {
let r = run(
&["review", "run", "--claude", "--json"],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
assert_eq!(
claim_containing(&report["claims"], "Tuesdays")["verdict"],
"stale"
);
assert_eq!(claude_calls(fake.path()).len(), expected);
}
let r = run(&["review", "run", "--json"], repo.path(), &env, None);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
assert_eq!(
claim_containing(&report["claims"], "Tuesdays")["verdict"],
"stale"
);
assert_eq!(claude_calls(fake.path()).len(), 2);
}
#[cfg(unix)]
#[test]
fn a_files_run_keeps_the_other_files_answers() {
let repo = review_repo();
let (fake, path) = fake_claude(C1_STALE);
let env = [("PATH", path.as_str())];
write_memory(
repo.path(),
&env,
"a.md",
"- The deploy happens on Tuesdays.\n",
);
write_memory(
repo.path(),
&env,
"b.md",
"- The backup happens on Fridays.\n",
);
let r = run(&["review", "run", "--claude"], repo.path(), &env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
assert_eq!(claude_calls(fake.path()).len(), 2);
write_memory(
repo.path(),
&env,
"a.md",
"- The deploy happens on Thursdays.\n",
);
let r = run(
&["review", "run", "a.md", "--claude", "--json"],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "{}", r.stderr);
assert_eq!(claude_calls(fake.path()).len(), 3);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
assert_eq!(
claim_containing(&report["claims"], "Fridays")["verdict"],
"stale"
);
assert_eq!(
claim_containing(&report["claims"], "Thursdays")["verdict"],
"stale"
);
let r = run(&["review", "run", "--json"], repo.path(), &env, None);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
assert_eq!(
claim_containing(&report["claims"], "Fridays")["verdict"],
"stale"
);
assert_eq!(claude_calls(fake.path()).len(), 3);
}
#[cfg(unix)]
#[test]
fn what_decided_nothing_is_context_not_a_citable_fact() {
let repo = review_repo();
let (fake, path) = fake_claude(C1_STALE);
let env = [("PATH", path.as_str())];
write_memory(
repo.path(),
&env,
"data.md",
"- The archive lives in /srv/recall-nowhere/archive.\n",
);
let r = run(&["review", "run", "--claude"], repo.path(), &env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
let calls = claude_calls(fake.path());
assert_eq!(calls.len(), 1);
let after_note = calls[0]
.split_once("=== Facts observed")
.unwrap_or_else(|| panic!("no facts section: {}", calls[0]))
.1;
let (facts, rest) = after_note
.split_once("=== Context")
.unwrap_or_else(|| panic!("no context section: {}", calls[0]));
assert!(!facts.contains("/srv/recall-nowhere/archive"), "{facts}");
let context = rest.split("=== Claims to judge ===").next().unwrap();
assert!(context.contains("/srv/recall-nowhere/archive"), "{context}");
}
#[cfg(unix)]
const C1_STALE_REWRITTEN: &str = r#"{"claims":[{"id":"C1","class":"present","verdict":"stale","cites":["E1"],"reason":"The facts say otherwise.","rewrite":"Until 2026-09 the deploy happened on Tuesdays; it now happens on Wednesdays."}]}"#;
#[cfg(unix)]
#[test]
fn review_apply_rewrites_a_stale_claim_into_a_record_and_pushes_it() {
let server = live_server("right");
let repo = git_repo();
let home = recall_home_with(&[(&server.url, "right")], &server.url);
let home_str = home.path().to_string_lossy().to_string();
let (_fake, path) = fake_claude(C1_STALE_REWRITTEN);
let env = [("RECALL_HOME", home_str.as_str()), ("PATH", path.as_str())];
let content = "# Deploy\n\nThe deploy happens on Tuesdays. It used to happen on Mondays.\n";
let pushed = push_memory(&repo, &env, "deploy.md", content);
assert_eq!(pushed.code, 0, "{}", pushed.stderr);
let r = run(
&["review", "run", "--claude", "--json"],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let claim = claim_containing(&report["claims"], "happens on Tuesdays");
assert_eq!(claim["verdict"], "stale", "{claim}");
let edit = &claim["suggested_edit"];
assert_eq!(edit["file_path"], "deploy.md", "{claim}");
assert_eq!(edit["lines"], serde_json::json!([3, 3]), "{claim}");
let id = claim["id"].as_str().unwrap().to_string();
let shown = run(&["review", "show"], repo.path(), &env, None);
assert!(
shown.stdout.contains(&format!("recall review apply {id}")),
"{}",
shown.stdout
);
let applied = run(&["review", "apply", &id, "--yes"], repo.path(), &env, None);
assert_eq!(applied.code, 0, "{}", applied.stderr);
let fixed = "# Deploy\n\nUntil 2026-09 the deploy happened on Tuesdays; it now happens on \
Wednesdays. It used to happen on Mondays.\n";
let memory_dir = status_json(repo.path(), &env)["memory_dir"]
.as_str()
.unwrap()
.to_string();
assert_eq!(
std::fs::read_to_string(Path::new(&memory_dir).join("deploy.md")).unwrap(),
fixed
);
let on_server = stored(&server, &repo, &env);
let file = on_server
.iter()
.find(|f| f.file_path == "deploy.md")
.unwrap();
assert_eq!(file.content.as_deref(), Some(fixed));
let again = run(&["review", "apply", &id, "--yes"], repo.path(), &env, None);
assert_eq!(again.code, 1, "{}", again.stdout);
assert!(
again.stderr.contains("recall review run --claude"),
"{}",
again.stderr
);
}
#[cfg(unix)]
#[test]
fn review_apply_refuses_what_it_cannot_do() {
let repo = review_repo();
let (_fake, path) = fake_claude(C1_STALE);
let env = [("PATH", path.as_str())];
let none = run(&["review", "apply", "t1", "--yes"], repo.path(), &env, None);
assert_eq!(none.code, 1);
assert!(none.stderr.contains("recall review run"), "{}", none.stderr);
write_memory(
repo.path(),
&env,
"plans.md",
"- The deploy happens on Tuesdays.\n",
);
let r = run(
&["review", "run", "--claude", "--json"],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let claim = claim_containing(&report["claims"], "Tuesdays");
assert!(claim["suggested_edit"].is_null(), "{claim}");
let id = claim["id"].as_str().unwrap().to_string();
let r = run(&["review", "apply", &id, "--yes"], repo.path(), &env, None);
assert_eq!(r.code, 1);
assert!(r.stderr.contains("has no edit to make"), "{}", r.stderr);
let r = run(
&["review", "apply", "t99", "--yes"],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 1);
assert!(r.stderr.contains("recall review show"), "{}", r.stderr);
}
#[cfg(unix)]
#[test]
fn a_claim_holding_a_secret_gets_no_rewrite() {
let repo = review_repo();
let (_fake, path) = fake_claude(C1_STALE_REWRITTEN);
let env = [("PATH", path.as_str())];
let token = format!("ghp_{}", "Zx9Yw8Vu7T".repeat(4));
write_memory(
repo.path(),
&env,
"creds.md",
&format!("- The deploy key is {token} and it opens the vault.\n"),
);
let r = run(
&["review", "run", "--claude", "--json"],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let claim = claim_containing(&report["claims"], "opens the vault");
assert_eq!(claim["verdict"], "stale", "{claim}");
assert!(claim["suggested_edit"].is_null(), "{claim}");
}
#[cfg(unix)]
#[test]
fn review_apply_builds_the_edit_again_and_trusts_no_stored_one() {
let server = live_server("right");
let repo = git_repo();
let home = recall_home_with(&[(&server.url, "right")], &server.url);
let home_str = home.path().to_string_lossy().to_string();
let (_fake, path) = fake_claude(C1_STALE_REWRITTEN);
let env = [("RECALL_HOME", home_str.as_str()), ("PATH", path.as_str())];
let content = "# Deploy\n\nThe deploy happens on Tuesdays. It used to happen on Mondays.\n";
assert_eq!(push_memory(&repo, &env, "deploy.md", content).code, 0);
let r = run(
&["review", "run", "--claude", "--json"],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let id = claim_containing(&report["claims"], "Tuesdays")["id"]
.as_str()
.unwrap()
.to_string();
let memory_dir = PathBuf::from(
status_json(repo.path(), &env)["memory_dir"]
.as_str()
.unwrap(),
);
let note = memory_dir.join("deploy.md");
let state_file = memory_dir.parent().unwrap().join(".recall-review.json");
let original = std::fs::read_to_string(&state_file).unwrap();
let asked = run(&["review", "apply", &id], repo.path(), &env, None);
assert_eq!(asked.code, 1, "{}", asked.stdout);
assert!(
asked.stderr.contains("needs a terminal"),
"{}",
asked.stderr
);
assert_eq!(std::fs::read_to_string(¬e).unwrap(), content);
let mut state: serde_json::Value = serde_json::from_str(&original).unwrap();
state["files"]["deploy.md"]["claude"]["decided"][0]["rewrite"] =
"The deploy happens on Wednesdays.".into();
std::fs::write(&state_file, state.to_string()).unwrap();
let r = run(&["review", "apply", &id, "--yes"], repo.path(), &env, None);
assert_eq!(r.code, 1, "{}", r.stdout);
assert!(r.stderr.contains("has no edit to make"), "{}", r.stderr);
assert_eq!(std::fs::read_to_string(¬e).unwrap(), content);
let mut state: serde_json::Value = serde_json::from_str(&original).unwrap();
for claim in state["report"]["claims"].as_array_mut().unwrap() {
if claim["id"] == id.as_str() {
claim["suggested_edit"]["replacement"] = "HACKED\n".into();
claim["suggested_edit"]["lines"] = serde_json::json!([1, 3]);
}
}
std::fs::write(&state_file, state.to_string()).unwrap();
let r = run(&["review", "apply", &id, "--yes"], repo.path(), &env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
assert_eq!(
std::fs::read_to_string(¬e).unwrap(),
"# Deploy\n\nUntil 2026-09 the deploy happened on Tuesdays; it now happens on \
Wednesdays. It used to happen on Mondays.\n"
);
}
#[test]
fn a_reports_findings_are_shown_beside_the_claims_they_cover() {
let server = live_server("right");
let repo = git_repo();
let home = recall_home_with(&[(&server.url, "right")], &server.url);
let home_str = home.path().to_string_lossy().to_string();
let env = [("RECALL_HOME", home_str.as_str())];
let content = "# Deploy\n- key: abc123\n- ship it\n";
assert_eq!(push_memory(&repo, &env, "deploy.md", content).code, 0);
let key = status_json(repo.path(), &env)["project_key"]
.as_str()
.unwrap()
.to_string();
plant_report(&server, &key, content);
let r = run(&["review", "run", "--json"], repo.path(), &env, None);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
assert_eq!(
report["evidence"]["evaluation"], "eval_cli",
"{}",
report["evidence"]
);
let covered = claim_containing(&report["claims"], "key: abc123");
assert_eq!(covered["eval"][0]["evaluation"], "eval_cli", "{covered}");
assert_eq!(covered["eval"][0]["finding"], "f1", "{covered}");
assert_eq!(covered["eval"][0]["kind"], "secret", "{covered}");
let beside = claim_containing(&report["claims"], "ship it");
assert!(beside["eval"].is_null(), "{beside}");
let shown = run(&["review", "show"], repo.path(), &env, None);
assert!(
shown.stdout.contains("eval_cli f1 secret (high), on t"),
"{}",
shown.stdout
);
let url_only = [("RECALL_URL", server.url.as_str())];
let r = run(&["review", "run", "--json"], repo.path(), &url_only, None);
assert_eq!(r.code, 0, "{}", r.stderr);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
assert!(
report["evidence"]["evaluation"].is_null(),
"{}",
report["evidence"]
);
let unavailable = report["evidence"]["unavailable"].to_string();
assert!(unavailable.contains("\"evaluation\""), "{unavailable}");
}
#[cfg(unix)]
fn claude_script(script: &str) -> (tempfile::TempDir, String) {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("claude");
std::fs::write(
&path,
format!("#!/bin/sh\nd='{}'\n{script}", dir.path().display()),
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
let env_path = format!(
"{}:{}",
dir.path().display(),
std::env::var("PATH").unwrap_or_default()
);
(dir, env_path)
}
#[cfg(unix)]
#[test]
fn claude_not_logged_in_is_said_once_before_any_call() {
let repo = review_repo();
let (fake, path) = claude_script(
"if [ \"$1\" = auth ]; then echo '{\"loggedIn\":false}'; exit 1; fi\n: > \"$d/called\"\n",
);
let env = [("PATH", path.as_str())];
write_memory(
repo.path(),
&env,
"a.md",
"- The deploy happens on Tuesdays.\n",
);
write_memory(
repo.path(),
&env,
"b.md",
"- The backup happens on Fridays.\n",
);
let r = run(
&["review", "run", "--claude", "--json"],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "{}", r.stderr);
assert!(!fake.path().join("called").exists(), "a call was made");
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let skipped = report["evidence"]["claude"]["skipped"].to_string();
assert!(skipped.contains("not logged in"), "{skipped}");
assert_eq!(report["evidence"]["claude"]["calls"], 0);
}
#[cfg(unix)]
#[test]
fn a_claude_failure_says_why_and_stops_asking() {
let repo = review_repo();
let (fake, path) = claude_script(concat!(
"if [ \"$1\" = auth ]; then echo '{\"loggedIn\":true}'; exit 0; fi\n",
"cat > /dev/null; : >> \"$d/calls\"; echo x >> \"$d/calls\"\n",
"echo '{\"type\":\"result\",\"is_error\":true,\"result\":\"Credit balance is too low\"}'; exit 1\n",
));
let env = [("PATH", path.as_str())];
write_memory(
repo.path(),
&env,
"a.md",
"- The deploy happens on Tuesdays.\n",
);
write_memory(
repo.path(),
&env,
"b.md",
"- The backup happens on Fridays.\n",
);
let r = run(
&["review", "run", "--claude", "--json"],
repo.path(),
&env,
None,
);
assert_eq!(r.code, 0, "{}", r.stderr);
let calls = std::fs::read_to_string(fake.path().join("calls")).unwrap();
assert_eq!(calls.lines().count(), 1, "asked again after a failure");
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let skipped = report["evidence"]["claude"]["skipped"]
.as_array()
.unwrap()
.clone();
assert!(
skipped[0]["reason"]
.as_str()
.unwrap()
.contains("Credit balance is too low"),
"{skipped:?}"
);
assert!(
skipped[1]["reason"].as_str().unwrap().contains("not asked"),
"{skipped:?}"
);
}