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("claim(s) need you"), "{}", text.stdout);
assert!(
text.stdout.contains("1 line(s) of history"),
"records are counted, not listed: {}",
text.stdout
);
assert!(
!text.stdout.contains(" 0 line(s) of history") && !text.stdout.contains(" 0 with nothing"),
"{}",
text.stdout
);
for wanted in [
"Why: this reads as how things are now",
"→ If it is about now, fix it: edit L6 and write what is true now, then recall sync",
"→ If it is history, right as written: recall review dismiss t2",
"project_phase1_deploy.md ",
"What to do now",
"1. t2 project_phase1_deploy.md L6: edit it (write what is true now),",
"or recall review dismiss t2 if it is history",
"Commands",
"recall review apply tN Writes tN's suggested fix",
"recall review dismiss tN Marks tN as history",
"recall sync Sends a note you edited outside Claude Code.",
] {
assert!(text.stdout.contains(wanted), "{wanted:?}: {}", text.stdout);
}
assert!(
!text.stdout.contains("stale") || !text.stdout.contains("Not the old"),
"{}",
text.stdout
);
}
#[test]
fn a_dismissed_claim_stops_needing_you_until_its_text_changes() {
let repo = review_repo();
let env: Vec<(&str, &str)> = vec![];
let note = |line: &str| format!("---\nname: n\n---\n\n- {line}\n");
write_memory(
repo.path(),
&env,
"project_hooks.md",
¬e("Run `lib.sh` to start the hooks."),
);
let first = run(&["review", "run"], repo.path(), &env, None);
assert!(
first.stdout.contains("1 claim(s) need you"),
"{}",
first.stdout
);
let unknown = run(&["review", "dismiss", "t9"], repo.path(), &env, None);
assert_ne!(unknown.code, 0);
assert!(unknown.stderr.contains("no claim t9"), "{}", unknown.stderr);
let d = run(&["review", "dismiss", "t1"], repo.path(), &env, None);
assert_eq!(d.code, 0, "{}", d.stderr);
assert!(d.stdout.contains("Dismissed t1"), "{}", d.stdout);
assert!(
d.stdout.contains("recall review dismiss t1 --undo"),
"{}",
d.stdout
);
for args in [&["review", "show"][..], &["review", "run"][..]] {
let r = run(args, repo.path(), &env, None);
assert!(
r.stdout.contains("Nothing to fix"),
"{args:?}: {}",
r.stdout
);
assert!(
r.stdout.contains("1 you dismissed as history"),
"{args:?}: {}",
r.stdout
);
assert!(r.stdout.contains("✗ 0 stale"), "{args:?}: {}", r.stdout);
assert!(r.stdout.contains("Nothing."), "{args:?}: {}", r.stdout);
}
let json = run(&["review", "show", "--json"], repo.path(), &env, None);
let report: serde_json::Value = serde_json::from_str(&json.stdout).unwrap();
let claim = claim_containing(&report["claims"], "Run `lib.sh`");
assert_eq!(claim["dismissed"], true, "{claim}");
assert_eq!(
claim["verdict"], "stale",
"the verdict itself is kept: {claim}"
);
let details = run(&["review", "show", "--details"], repo.path(), &env, None);
assert!(
details
.stdout
.contains("dismissed as history; recall review dismiss t1 --undo flags it again"),
"{}",
details.stdout
);
let undo = run(
&["review", "dismiss", "t1", "--undo"],
repo.path(),
&env,
None,
);
assert_eq!(undo.code, 0, "{}", undo.stderr);
let back = run(&["review", "show"], repo.path(), &env, None);
assert!(
back.stdout.contains("1 claim(s) need you"),
"{}",
back.stdout
);
let again = run(
&["review", "dismiss", "t1", "--undo"],
repo.path(),
&env,
None,
);
assert_ne!(again.code, 0);
assert!(again.stderr.contains("not dismissed"), "{}", again.stderr);
run(&["review", "dismiss", "t1"], repo.path(), &env, None);
write_memory(
repo.path(),
&env,
"project_hooks.md",
¬e("Run `lib.sh` to start every hook."),
);
let changed = run(&["review", "run"], repo.path(), &env, None);
assert!(
changed.stdout.contains("1 claim(s) need you"),
"{}",
changed.stdout
);
write_memory(
repo.path(),
&env,
"project_hooks.md",
¬e("Run `docs/plan.md` first."),
);
run(&["review", "run"], repo.path(), &env, None);
let not_stale = run(&["review", "dismiss", "t1"], repo.path(), &env, None);
assert_ne!(not_stale.code, 0);
assert!(
not_stale.stderr.contains("not stale"),
"{}",
not_stale.stderr
);
}
#[test]
fn a_renamed_path_says_what_to_write_instead() {
let repo = review_repo();
let git = |args: &[&str]| {
assert!(Command::new("git")
.args(args)
.current_dir(repo.path())
.status()
.unwrap()
.success());
};
let workflows = repo.path().join(".github").join("workflows");
std::fs::create_dir_all(&workflows).unwrap();
std::fs::write(
workflows.join("cut-release.yml"),
"name: Cut a release\non: workflow_dispatch\njobs:\n tag:\n runs-on: ubuntu-latest\n",
)
.unwrap();
git(&["add", "-A"]);
git(&["commit", "-q", "-m", "add cut-release.yml"]);
git(&[
"mv",
".github/workflows/cut-release.yml",
".github/workflows/start-release.yml",
]);
git(&["commit", "-q", "-m", "rename it"]);
let env: Vec<(&str, &str)> = vec![];
write_memory(
repo.path(),
&env,
"project_release.md",
"---\nname: r\n---\n\n- The release workflow lives in `cut-release.yml` on main.\n",
);
let r = run(&["review", "run", "--json"], repo.path(), &env, None);
let report: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let claim = claim_containing(&report["claims"], "The release workflow");
assert_eq!(claim["verdict"], "stale", "{claim}");
let renamed = &claim["evidence"][0]["renamed"];
assert_eq!(renamed["from"], "cut-release.yml", "{claim}");
assert_eq!(renamed["to"], "start-release.yml", "{claim}");
let text = run(&["review", "show"], repo.path(), &env, None);
assert!(
text.stdout
.contains("`cut-release.yml` was renamed to `start-release.yml`"),
"{}",
text.stdout
);
assert!(
text.stdout
.contains("edit L5 and replace `cut-release.yml` with `start-release.yml`"),
"{}",
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("Nothing to fix"), "{}", text.stdout);
assert!(text.stdout.contains("1 still hold"), "{}", text.stdout);
let text = run(&["review", "show", "--details"], repo.path(), &env, None);
assert_eq!(text.code, 0);
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("claude was not asked about 1 file(s)")
&& r.stdout.contains("--max-calls 1"),
"{}",
r.stdout
);
let r = run(&["review", "show", "--details"], 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:?}"
);
let claude = &report["evidence"]["claude"];
assert!(
claude["failure"]
.as_str()
.unwrap()
.contains("Credit balance is too low"),
"{claude}"
);
let binary = claude["binary"].as_str().unwrap();
assert!(
binary.starts_with(&fake.path().display().to_string()),
"{binary}"
);
let text = run(&["review", "show"], repo.path(), &env, None);
let warning = text
.stdout
.find("claude could not answer, so 2 file(s) got no answer")
.unwrap_or_else(|| panic!("{}", text.stdout));
let summary = text
.stdout
.find("still true")
.unwrap_or_else(|| panic!("{}", text.stdout));
assert!(warning < summary, "{}", text.stdout);
assert!(text.stdout.contains(binary), "{}", text.stdout);
assert!(
text.stdout.contains("claude -p \"hello\""),
"{}",
text.stdout
);
}
#[test]
fn a_checkout_behind_its_upstream_is_said_first() {
let upstream = review_repo();
let clone = tempfile::tempdir().unwrap();
let git = |dir: &Path, args: &[&str]| {
assert!(Command::new("git")
.args(args)
.current_dir(dir)
.status()
.unwrap()
.success());
};
git(
clone.path(),
&["clone", "-q", &upstream.path().display().to_string(), "."],
);
git(clone.path(), &["checkout", "-q", "--detach", "HEAD~1"]);
let env: Vec<(&str, &str)> = vec![];
write_memory(
clone.path(),
&env,
"plan.md",
"- The plan lives in `docs/plan.md`.\n",
);
let json = run(&["review", "run", "--json"], clone.path(), &env, None);
assert_eq!(json.code, 0, "{}", json.stderr);
let report: serde_json::Value = serde_json::from_str(&json.stdout).unwrap();
let ev = &report["evidence"];
assert_eq!(ev["repository_detached"], true, "{ev}");
assert_eq!(ev["repository_behind"], 1, "{ev}");
let text = run(&["review", "show"], clone.path(), &env, None);
assert!(
text.stdout.contains("This checkout is a detached HEAD at")
&& text.stdout.contains("1 commit(s) behind"),
"{}",
text.stdout
);
assert!(text.stdout.contains("git switch "), "{}", text.stdout);
let text = run(&["review", "run"], upstream.path(), &env, None);
assert!(!text.stdout.contains("This checkout is"), "{}", text.stdout);
}