use recall_hooks::config::Source;
use recall_hooks::{exit, ClientConfig};
use crate::project as proj;
use crate::status::{self, Report};
use crate::ui;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Level {
Ok,
Warn,
Fail,
}
#[derive(Debug, serde::Serialize)]
pub struct Finding {
pub level: Level,
pub check: &'static str,
pub detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub fix: Option<String>,
}
fn ok(check: &'static str, detail: impl Into<String>) -> Finding {
Finding {
level: Level::Ok,
check,
detail: detail.into(),
fix: None,
}
}
fn warn(check: &'static str, detail: impl Into<String>, fix: impl Into<String>) -> Finding {
Finding {
level: Level::Warn,
check,
detail: detail.into(),
fix: Some(fix.into()),
}
}
fn fail(check: &'static str, detail: impl Into<String>, fix: impl Into<String>) -> Finding {
Finding {
level: Level::Fail,
check,
detail: detail.into(),
fix: Some(fix.into()),
}
}
const WHERE_TO_SET: &str =
"cloud environment: the \"Add/Edit cloud environment\" dialog; laptop: recall connect <url>";
pub(crate) fn findings(rep: &Report) -> Vec<Finding> {
let mut out = Vec::new();
if rep.url_set {
out.push(ok("RECALL_URL", source_detail(rep, rep.url_source)));
} else {
out.push(fail("RECALL_URL", "not set anywhere", WHERE_TO_SET));
}
if rep.token_set {
out.push(ok("RECALL_TOKEN", source_detail(rep, rep.token_source)));
} else if rep.device.is_some() {
out.push(ok(
"RECALL_TOKEN",
"not needed, this machine signs its requests with its device key",
));
} else if rep.authkey_set {
out.push(ok(
"RECALL_TOKEN",
"not needed, RECALL_AUTHKEY enrols this session as a device",
));
} else {
out.push(fail("RECALL_TOKEN", "not set anywhere", WHERE_TO_SET));
}
credentials_findings(rep, &mut out);
device_findings(rep, &mut out);
if rep.url_set {
match (&rep.server_error, &rep.git_commit) {
(Some(err), _) => out.push(fail(
"server",
format!("unreachable: {err}"),
"check RECALL_URL. A cloud environment also needs the domain under \
Allowed domains",
)),
(None, _) if rep.server_version.is_some() => out.push(ok(
"server",
format!(
"answered, {}{}",
rep.server_version.as_deref().unwrap_or_default(),
if rep.server_channel.as_deref() == Some("dev") {
" (not a release build)"
} else {
""
}
),
)),
(None, Some(commit)) => out.push(ok("server", format!("answered, commit {commit}"))),
(None, None) => out.push(ok("server", "answered")),
}
version_findings(rep, &mut out);
}
let quiet = worker_quiet(rep).filter(|_| rep.server_ok);
if let Some(quiet) = quiet {
out.push(warn(
"merge",
format!(
"the merge worker has not asked for work in {} minutes, so conflicting \
edits wait in its queue unmerged, unless the server's own Claude CLI \
can merge them",
quiet.whole_minutes()
),
"on the server: docker compose logs recall-worker, and check it is running",
));
} else if rep.server_ok && !rep.merge_ready && rep.merge_worker {
out.push(warn(
"merge",
"the merge worker's Claude CLI is not logged in, so conflicting edits wait \
in its queue unmerged",
"on the server: docker compose exec -it -u node recall-worker claude setup-token",
));
} else if rep.server_ok && !rep.merge_ready {
out.push(warn(
"merge",
"the server's Claude CLI is not logged in, so conflicting edits use \
last-write-wins",
"on the server: docker compose exec -it -u node recall-server claude setup-token",
));
}
if rep.server_ok {
queue_finding(rep, &mut out);
}
match (rep.hooks_wired, rep.in_git_repo) {
(true, _) => out.push(ok("hooks", "wired in .claude/settings.json")),
(false, true) => out.push(fail(
"hooks",
"this project's .claude/settings.json has no Recall hooks",
"recall init",
)),
(false, false) => out.push(ok("hooks", "not in a git repository")),
}
#[cfg(windows)]
out.push(git_bash_finding());
match (rep.remote_session, rep.remote_memory_dir_set) {
(true, false) => out.push(fail(
"CLAUDE_CODE_REMOTE_MEMORY_DIR",
"not set, so auto-memory is off in this remote session",
"set it to /home/user/.claude on the cloud environment (not $HOME)",
)),
(true, true) => out.push(ok("CLAUDE_CODE_REMOTE_MEMORY_DIR", "set")),
(false, true) => out.push(ok(
"CLAUDE_CODE_REMOTE_MEMORY_DIR",
"set, memory lives here instead of ~/.claude",
)),
(false, false) => out.push(ok(
"CLAUDE_CODE_REMOTE_MEMORY_DIR",
"not needed outside a remote session",
)),
}
out.push(ok(
"memory dir",
format!("{} ({} files)", rep.memory_dir, rep.memory_files),
));
offbox_finding(rep, &mut out);
reserved_findings(rep, &mut out);
if let Some(mis) = &rep.miscased_dir {
out.push(fail(
"reserved directory",
format!(
"{}/ should be {}/ (case matters), so nothing under it syncs",
mis.found, mis.reserved
),
format!("rename {}/ to {}/", mis.found, mis.reserved),
));
}
for var in &rep.rejected_vars {
out.push(fail(
"rejected value",
format!("{var} is set to a value Recall refused, so it did nothing"),
format!("recall status names the file behind {var}"),
));
}
for file in &rep.unreadable_settings {
out.push(fail(
"settings file",
format!("{file} is not readable JSON, so nothing it declares is in effect"),
"fix the JSON, Claude Code cannot read it either".to_string(),
));
}
for ig in &rep.ignored_env {
out.push(warn(
"ignored value",
format!(
"{} is declared in {} but its value is not a string, so it was skipped",
ig.name, ig.file
),
"quote the value".to_string(),
));
}
out
}
fn device_findings(rep: &Report, out: &mut Vec<Finding>) {
let key_file = rep.device_file.as_deref().unwrap_or("~/.recall/device.key");
if let Some(err) = &rep.device_error {
out.push(fail(
"device key",
format!("{err}, so this machine sends nothing to the server"),
"move it aside and run recall connect to enrol again",
));
}
if rep.device_file_exposed {
out.push(warn(
"device key",
format!("{key_file} is readable by other users"),
format!("chmod 600 {key_file}"),
));
}
let Some(d) = &rep.device else {
match (rep.server_devices, rep.remote_session) {
(Some(true), true) if rep.authkey_set => out.push(warn(
"device",
"RECALL_AUTHKEY is set, but this session has not enrolled yet",
"recall pull enrols it, and says why when it cannot",
)),
(Some(true), true) if rep.token_set => out.push(warn(
"device",
"this session uses the shared RECALL_TOKEN",
"on an admin device: recall authkey create --tag cloud --expires 90d, \
then set RECALL_AUTHKEY on the cloud environment and remove RECALL_TOKEN",
)),
(Some(true), false) if rep.token_set => out.push(warn(
"device",
"not enrolled, so this machine still uses the shared RECALL_TOKEN",
"recall connect",
)),
(Some(false), _) => out.push(ok(
"device",
"not available, the server does not enrol devices",
)),
_ => {}
}
return;
};
let what = format!(
"{} ({}{})",
d.name,
d.scope,
if d.ephemeral { ", ephemeral" } else { "" }
);
let reenroll = if rep.remote_session && rep.authkey_set {
"the next session start enrols again with RECALL_AUTHKEY"
} else {
"recall connect"
};
match d.confirmed {
Some(true) => out.push(ok(
"device",
format!(
"enrolled as {what}, key in {} ({})",
d.key_file,
key_protection(&d.key_file)
),
)),
Some(false) if d.gone => out.push(fail(
"device",
format!(
"the server no longer accepts this machine's device {}: {}",
d.name,
d.check_error.as_deref().unwrap_or("unknown device")
),
reenroll,
)),
Some(false) if rep.server_devices == Some(false) => out.push(fail(
"device",
format!(
"this machine signs its requests as {}, and the server does not accept \
device signatures",
d.name
),
"upgrade the server to 0.4.1 or later",
)),
Some(false) => out.push(warn(
"device",
format!(
"{what}: the server did not confirm it: {}",
d.check_error.as_deref().unwrap_or("no answer")
),
"run recall doctor again; if it persists, recall connect",
)),
None => out.push(ok(
"device",
format!("enrolled as {what}, key in {} (not checked)", d.key_file),
)),
}
if rep.token_set {
let (detail, fix) = match rep.token_source {
Source::CredentialsFile => (
format!(
"RECALL_TOKEN is still saved in {}, though this machine never sends it",
rep.credentials_file
.as_deref()
.unwrap_or("the credentials file")
),
"recall connect removes it".to_string(),
),
_ => {
let from = rep
.declared_env
.iter()
.find(|d| d.name == "RECALL_TOKEN")
.map(|d| d.file.clone())
.unwrap_or_else(|| {
if rep.remote_session {
"the cloud environment's variables".to_string()
} else {
"your shell profile".to_string()
}
});
(
"RECALL_TOKEN is still set, though this machine never sends it".to_string(),
format!("remove RECALL_TOKEN from {from}"),
)
}
};
out.push(warn("shared token", detail, fix));
}
}
#[cfg(unix)]
fn key_protection(_key_file: &str) -> &'static str {
"a file readable by you only"
}
#[cfg(not(unix))]
fn key_protection(key_file: &str) -> &'static str {
let profile = std::env::var("USERPROFILE").unwrap_or_default();
if recall_hooks::home::inside_profile(std::path::Path::new(key_file), &profile) {
"a file in your user profile, which only you and administrators can read"
} else {
"a file outside your user profile, so who else can read it is up to that directory"
}
}
fn version_findings(rep: &Report, out: &mut Vec<Finding>) {
if !rep.server_protocols.is_empty() && !rep.server_protocols.contains(&recall_wire::PROTOCOL) {
out.push(fail(
"version",
format!(
"the server speaks protocol {:?} and this client speaks {}",
rep.server_protocols,
recall_wire::PROTOCOL
),
"upgrade whichever side is older",
));
}
let too_old = rep.min_client.as_deref().and_then(|min| {
let min_v = recall_wire::discovery::Version::parse(min)?;
let mine = recall_wire::discovery::Version::parse(&rep.client_version)?;
(mine < min_v).then(|| min.to_string())
});
if let Some(min) = too_old {
out.push(fail(
"version",
format!(
"this client is {} and the server needs at least {min}",
rep.client_version
),
"upgrade recall: brew upgrade recall, or npm install -g @pimlabs/recall",
));
}
}
#[cfg(windows)]
fn git_bash_finding() -> Finding {
if git_bash_available() {
ok("Git Bash", "found")
} else {
fail(
"Git Bash",
"not found, so Claude Code cannot run the hooks recall init writes \
(it falls back to PowerShell, which the committed hook command \
cannot run)",
"install Git for Windows: https://git-scm.com/download/win",
)
}
}
#[cfg(windows)]
fn git_bash_available() -> bool {
if let Ok(path) = std::env::var("CLAUDE_CODE_GIT_BASH_PATH") {
if !path.is_empty() && std::path::Path::new(&path).is_file() {
return true;
}
}
if path_bash_is_git_bash() {
return true;
}
for candidate in [
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files (x86)\Git\bin\bash.exe",
] {
if std::path::Path::new(candidate).is_file() {
return true;
}
}
false
}
#[cfg(windows)]
fn path_bash_is_git_bash() -> bool {
std::env::var_os("PATH")
.map(|paths| {
std::env::split_paths(&paths).any(|dir| {
dir.join("bash.exe").is_file()
&& dir.to_string_lossy().to_ascii_lowercase().contains("git")
})
})
.unwrap_or(false)
}
fn source_detail(rep: &Report, source: Source) -> String {
match source {
Source::CredentialsFile => format!(
"saved in {}",
rep.credentials_file
.as_deref()
.unwrap_or("the credentials file")
),
Source::ConfigFile => format!(
"saved in {}",
rep.config_file.as_deref().unwrap_or("the config file")
),
_ => "set".to_string(),
}
}
fn credentials_findings(rep: &Report, out: &mut Vec<Finding>) {
if rep.token_source == Source::Environment && !rep.remote_session && rep.device.is_none() {
let (detail, remove_from) = match rep.declared_env.iter().find(|d| d.name == "RECALL_TOKEN")
{
Some(d) => (
format!("RECALL_TOKEN is set in {}, in plain text", d.file),
d.file.clone(),
),
None => (
"RECALL_TOKEN is exported by your shell, so every program you start \
can read it"
.to_string(),
"your shell profile".to_string(),
),
};
out.push(warn(
"token storage",
detail,
format!(
"run recall connect to save it in ~/.recall/credentials.toml, then \
remove RECALL_TOKEN from {remove_from}"
),
));
}
let config_file = rep
.config_file
.as_deref()
.unwrap_or("~/.recall/config.toml");
for problem in &rep.config_problems {
out.push(warn(
"config file",
problem.clone(),
format!("edit {config_file}"),
));
}
for o in &rep.overridden {
let from = rep
.declared_env
.iter()
.find(|d| d.name == o.variable)
.map(|d| d.file.clone())
.unwrap_or_else(|| "your shell profile".to_string());
out.push(warn(
"config overridden",
format!(
"{}={} wins over {} = {:?} in {config_file}",
o.variable, o.environment, o.setting, o.config
),
format!(
"remove {} from {from}, or change {} to match",
o.variable, o.setting
),
));
}
if let Some(err) = &rep.credentials_error {
out.push(warn(
"credentials file",
format!("{err}, so nothing in it is in effect"),
"move it aside and run recall connect again",
));
}
if rep.credentials_exposed {
let file = rep
.credentials_file
.as_deref()
.unwrap_or("~/.recall/credentials.toml");
out.push(warn(
"credentials file",
format!("{file} is readable by other users"),
format!("chmod 600 {file}"),
));
}
}
const OFFBOX_STALE_AFTER: time::Duration = time::Duration::days(2);
fn offbox_finding(rep: &Report, out: &mut Vec<Finding>) {
let Some(stamp) = rep.last_offbox_at.as_deref() else {
return;
};
let Some(age) = age_of(stamp) else {
out.push(warn(
"off-box backup",
format!("the server reported a stamp this cannot read: {stamp}"),
"expected the API's timestamp format, e.g. 2026-09-22T00:17:03.000Z",
));
return;
};
if age > OFFBOX_STALE_AFTER {
out.push(warn(
"off-box backup",
format!(
"last verified copy is {} days old ({stamp}), anything newer exists \
only on the server",
age.whole_days()
),
"on the server: check the cron job's mail, then run \
RECALL_BACKUP_REMOTE=... ./deploy/backup-offbox.sh by hand",
));
} else {
out.push(ok("off-box backup", format!("verified {stamp}")));
}
}
const QUEUE_STALE_AFTER: time::Duration = time::Duration::hours(1);
const WORKER_QUIET_AFTER: time::Duration = time::Duration::minutes(2);
pub(crate) fn worker_quiet(rep: &Report) -> Option<time::Duration> {
if !rep.merge_worker {
return None;
}
rep.merge_worker_seen_at
.as_deref()
.and_then(age_of)
.filter(|quiet| *quiet >= WORKER_QUIET_AFTER)
}
fn queue_finding(rep: &Report, out: &mut Vec<Finding>) {
let Some(q) = &rep.merge_queue else {
return;
};
let waited = q.oldest_queued_at.as_deref().and_then(age_of);
match waited {
Some(age) if age >= QUEUE_STALE_AFTER => out.push(warn(
"merge queue",
format!(
"{} merge{} waiting, the oldest for {} minutes; pushes still land, \
unmerged, until the worker takes them",
q.queued,
if q.queued == 1 { "" } else { "s" },
age.whole_minutes()
),
"on the server: docker compose logs recall-worker, and check it is running",
)),
_ => out.push(ok(
"merge queue",
match q.queued {
0 => "nothing waiting".to_string(),
n => format!("{n} waiting"),
},
)),
}
if q.failed > 0 {
out.push(warn(
"failed merges",
format!(
"{} merge{} failed; for each, the newest push stands and the merge is kept \
in its job",
q.failed,
if q.failed == 1 { "" } else { "s" },
),
"list them with GET /v1/jobs?state=failed and retry one with \
POST /v1/jobs/{id}/retry, both with the operator token",
));
}
}
pub(crate) fn age_of(stamp: &str) -> Option<time::Duration> {
let fmt = time::macros::format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
);
let at = time::PrimitiveDateTime::parse(stamp, &fmt)
.ok()?
.assume_utc();
Some(time::OffsetDateTime::now_utc() - at)
}
fn reserved_findings(rep: &Report, out: &mut Vec<Finding>) {
let scopes: [(&'static str, &'static str, &Option<String>, usize, bool); 2] = [
(
"global scope",
"RECALL_GLOBAL_KEY",
&rep.global_key,
rep.global_files,
rep.global_linked,
),
(
"machine scope",
"RECALL_MACHINE_KEY",
&rep.machine_key,
rep.machine_files,
rep.machine_linked,
),
];
for (name, var, key, files, linked) in scopes {
match key {
None if files > 0 => out.push(warn(
name,
format!("off, but {files} file(s) are on disk under it and sync nowhere"),
format!("set {var}, or move the files out"),
)),
None => out.push(ok(name, "off")),
Some(k) if files > 0 && !linked => out.push(fail(
name,
format!(
"{k}: {files} file(s) synced, but MEMORY.md links none of them, so \
Claude Code never reads them"
),
"recall pull".to_string(),
)),
Some(k) => out.push(ok(name, format!("{k} ({files} files)"))),
}
}
}
pub async fn run(as_json: bool) -> anyhow::Result<i32> {
let here = proj::resolve();
let cfg = here.config();
let rep = status::collect(&here, &cfg).await;
let found = findings(&rep);
if as_json {
println!("{}", serde_json::to_string_pretty(&found)?);
} else {
print_text(&cfg, &rep, &found);
}
Ok(verdict(&found))
}
pub(crate) fn verdict(found: &[Finding]) -> i32 {
if found.iter().any(|f| f.level == Level::Fail) {
exit::CONFIG
} else {
exit::OK
}
}
const SECTIONS: &[(&str, &[&str])] = &[
(
"Connection",
&[
"RECALL_URL",
"RECALL_TOKEN",
"server",
"version",
"merge",
"merge queue",
"failed merges",
"token storage",
"credentials file",
"device",
"device key",
"shared token",
"config file",
"config overridden",
],
),
(
"This project",
&[
"hooks",
"Git Bash",
"memory dir",
"CLAUDE_CODE_REMOTE_MEMORY_DIR",
"settings file",
"ignored value",
"rejected value",
"reserved directory",
],
),
("Scopes", &["global scope", "machine scope"]),
("Backup", &["off-box backup"]),
];
const OTHER: &str = "Other";
fn section_of(check: &str) -> &'static str {
SECTIONS
.iter()
.find(|(_, checks)| checks.contains(&check))
.map(|(name, _)| *name)
.unwrap_or(OTHER)
}
fn tone_of(f: &Finding) -> ui::Tone {
match f.level {
Level::Fail => ui::Tone::Bad,
Level::Warn => ui::Tone::Warn,
Level::Ok
if f.detail == "off"
|| f.detail.starts_with("not needed")
|| f.detail.starts_with("not available")
|| f.detail.starts_with("not in a git repository") =>
{
ui::Tone::Quiet
}
Level::Ok => ui::Tone::Good,
}
}
fn print_text(cfg: &ClientConfig, rep: &Report, found: &[Finding]) {
let server = cfg
.url
.split_once("://")
.map(|(_, rest)| rest.trim_end_matches('/'))
.unwrap_or("no server");
ui::title("recall doctor", &format!("{} → {server}", cfg.source_env));
let width = found.iter().map(|f| f.check.len()).max().unwrap_or(0);
let names = SECTIONS.iter().map(|(n, _)| *n).chain([OTHER]);
for name in names {
let items: Vec<&Finding> = found
.iter()
.filter(|f| section_of(f.check) == name)
.collect();
if items.is_empty() {
continue;
}
let about = if name == "This project" {
rep.project_key.as_str()
} else {
""
};
ui::section(name, about);
for f in items {
ui::check(
tone_of(f),
f.check,
width,
&ui::tilde(&f.detail),
f.fix.as_deref().map(ui::tilde).as_deref(),
);
}
}
let fails = found.iter().filter(|f| f.level == Level::Fail).count();
let warns = found.iter().filter(|f| f.level == Level::Warn).count();
match (fails, warns) {
(0, 0) => ui::verdict(ui::Tone::Good, "Everything checks out."),
(0, w) => ui::verdict(
ui::Tone::Warn,
&format!("Nothing broken. {w} thing(s) worth a look."),
),
(f, _) => ui::verdict(
ui::Tone::Bad,
&format!(
"{f} problem(s). {}",
if cfg.url.is_empty() {
"Nothing syncs here."
} else {
"Some of it is not syncing."
}
),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::status::{KeySource, MiscasedDir};
fn healthy() -> Report {
Report {
project: "/w/app".into(),
project_key: "acme/app".into(),
project_key_source: KeySource::Remote,
memory_dir: "/w/memory".into(),
memory_files: 4,
hooks_wired: true,
in_git_repo: true,
remote_session: false,
declared_env: Vec::new(),
ignored_env: Vec::new(),
unreadable_settings: Vec::new(),
global_key: None,
rejected_vars: Vec::new(),
global_files: 0,
machine_key: None,
machine_files: 0,
machine_linked: false,
miscased_dir: None,
global_linked: false,
remote_memory_dir_set: true,
url_set: true,
token_set: true,
url_source: Source::ConfigFile,
token_source: Source::CredentialsFile,
credentials_file: Some("/h/.recall/credentials.json".into()),
credentials_error: None,
credentials_exposed: false,
auth: "bearer",
device: None,
device_file: Some("/h/.recall/device.key".into()),
device_error: None,
device_file_exposed: false,
authkey_set: false,
server_devices: None,
config_file: Some("/h/.recall/config.toml".into()),
machine_source: Source::Unset,
config_problems: Vec::new(),
overridden: Vec::new(),
server_ok: true,
server_error: None,
git_commit: Some("a1b2c3d".into()),
server_version: Some("0.3.2".into()),
server_channel: Some("release".into()),
server_protocols: vec![1],
min_client: Some("0.1.0".into()),
client_version: "0.3.3".into(),
merge_ready: true,
merge_worker: false,
merge_queue: None,
merge_worker_seen_at: None,
synced_files: 4,
last_synced_at: None,
last_offbox_at: None,
}
}
fn find<'a>(found: &'a [Finding], check: &str) -> Option<&'a Finding> {
found.iter().find(|f| f.check == check)
}
#[test]
fn a_healthy_setup_exits_zero_and_flags_nothing() {
let found = findings(&healthy());
assert_eq!(verdict(&found), exit::OK);
assert!(
found.iter().all(|f| f.level == Level::Ok),
"{:?}",
found
.iter()
.filter(|f| f.level != Level::Ok)
.collect::<Vec<_>>()
);
}
#[test]
fn an_unconfigured_environment_fails_rather_than_looking_idle() {
let mut rep = healthy();
rep.url_set = false;
rep.token_set = false;
rep.server_ok = false;
rep.git_commit = None;
let found = findings(&rep);
assert_eq!(find(&found, "RECALL_URL").unwrap().level, Level::Fail);
assert_eq!(find(&found, "RECALL_TOKEN").unwrap().level, Level::Fail);
assert_eq!(verdict(&found), exit::CONFIG);
}
#[test]
fn a_client_older_than_the_server_accepts_fails_and_says_to_upgrade() {
let mut r = healthy();
r.min_client = Some("0.4.0".into());
let found = findings(&r);
let v = found
.iter()
.find(|f| f.check == "version")
.expect("a version finding");
assert_eq!(v.level, Level::Fail);
assert!(
v.detail.contains("0.3.3") && v.detail.contains("0.4.0"),
"{}",
v.detail
);
}
#[test]
fn a_protocol_the_client_does_not_speak_fails() {
let mut r = healthy();
r.server_protocols = vec![2];
let found = findings(&r);
assert!(found
.iter()
.any(|f| f.check == "version" && f.level == Level::Fail));
}
#[test]
fn a_server_without_discovery_raises_no_version_finding() {
let mut r = healthy();
r.server_version = None;
r.server_channel = None;
r.server_protocols = Vec::new();
r.min_client = None;
let found = findings(&r);
assert!(!found.iter().any(|f| f.check == "version"));
let server = found.iter().find(|f| f.check == "server").unwrap();
assert!(server.detail.contains("a1b2c3d"), "{}", server.detail);
}
#[test]
fn a_dev_server_is_named_as_one() {
let mut r = healthy();
r.server_version = Some("0.3.3-dev+ge100cfd".into());
r.server_channel = Some("dev".into());
let server = findings(&r)
.into_iter()
.find(|f| f.check == "server")
.unwrap();
assert!(
server.detail.contains("not a release build"),
"{}",
server.detail
);
}
#[test]
fn the_server_is_not_reported_on_when_there_is_no_url() {
let mut rep = healthy();
rep.url_set = false;
rep.server_ok = false;
assert!(find(&findings(&rep), "server").is_none());
}
#[test]
fn a_shell_token_on_a_laptop_is_a_warning_that_names_the_fix() {
let mut rep = healthy();
rep.token_source = Source::Environment;
let found = findings(&rep);
let f = find(&found, "token storage").expect("a shell token is worth a word");
assert_eq!(f.level, Level::Warn);
assert!(f.detail.contains("shell"), "{}", f.detail);
assert!(f.fix.as_deref().unwrap().contains("recall connect"));
assert_eq!(
verdict(&found),
exit::OK,
"a warning never fails the command"
);
}
#[test]
fn a_token_in_the_environment_of_a_remote_session_is_fine() {
let mut rep = healthy();
rep.token_source = Source::Environment;
rep.remote_session = true;
assert!(find(&findings(&rep), "token storage").is_none());
}
#[test]
fn a_token_from_a_settings_file_names_the_file() {
let mut rep = healthy();
rep.token_source = Source::Environment;
rep.declared_env = vec![recall_hooks::declared_env::Declared {
name: "RECALL_TOKEN".into(),
file: "/w/app/.claude/settings.local.json".into(),
shadows_shell: false,
empty: false,
}];
let found = findings(&rep);
let f = find(&found, "token storage").unwrap();
assert!(f.detail.contains("settings.local.json"), "{}", f.detail);
assert!(f.fix.as_deref().unwrap().contains("settings.local.json"));
}
#[test]
fn a_credentials_file_others_can_read_is_a_warning_with_the_chmod() {
let mut rep = healthy();
rep.credentials_exposed = true;
let found = findings(&rep);
let f = find(&found, "credentials file").unwrap();
assert_eq!(f.level, Level::Warn);
assert!(f.fix.as_deref().unwrap().starts_with("chmod 600"));
}
#[test]
fn an_environment_value_overriding_the_config_is_named() {
let mut rep = healthy();
rep.overridden = vec![crate::status::Override {
variable: "RECALL_SOURCE_ENV",
environment: "laptop".into(),
setting: "machine.name",
config: "jarvis".into(),
}];
let found = findings(&rep);
let f = find(&found, "config overridden").unwrap();
assert_eq!(f.level, Level::Warn);
assert!(
f.detail.contains("laptop") && f.detail.contains("jarvis"),
"{}",
f.detail
);
assert!(f.fix.as_deref().unwrap().contains("RECALL_SOURCE_ENV"));
assert_eq!(verdict(&found), exit::OK);
}
#[test]
fn a_config_problem_is_a_warning_with_the_file_to_edit() {
let mut rep = healthy();
rep.config_problems = vec!["`machine.nmae` is not a setting Recall knows".into()];
let found = findings(&rep);
let f = find(&found, "config file").unwrap();
assert_eq!(f.level, Level::Warn);
assert!(f.fix.as_deref().unwrap().contains("config.toml"));
}
#[test]
fn warnings_alone_never_fail_the_command() {
let mut rep = healthy();
rep.remote_memory_dir_set = false;
rep.merge_ready = false;
let found = findings(&rep);
assert!(found.iter().any(|f| f.level == Level::Warn));
assert!(found.iter().all(|f| f.level != Level::Fail));
assert_eq!(verdict(&found), exit::OK);
}
#[test]
fn files_under_a_scope_that_is_off_are_reported() {
let mut rep = healthy();
rep.global_files = 3;
let found = findings(&rep);
let f = find(&found, "global scope").unwrap();
assert_eq!(f.level, Level::Warn);
assert!(f.detail.contains('3'), "{}", f.detail);
assert!(
f.fix.as_deref().unwrap().contains("RECALL_GLOBAL_KEY"),
"the fix has to name the variable that switches it on"
);
}
#[test]
fn a_scope_whose_files_memory_md_does_not_link_is_a_failure() {
let mut rep = healthy();
rep.machine_key = Some("machine:mbp".into());
rep.machine_files = 2;
rep.machine_linked = false;
let found = findings(&rep);
let f = find(&found, "machine scope").unwrap();
assert_eq!(f.level, Level::Fail);
assert!(f.detail.contains("MEMORY.md"), "{}", f.detail);
}
#[test]
fn a_linked_scope_with_files_is_fine() {
let mut rep = healthy();
rep.machine_key = Some("machine:mbp".into());
rep.machine_files = 2;
rep.machine_linked = true;
assert_eq!(
find(&findings(&rep), "machine scope").unwrap().level,
Level::Ok
);
}
#[test]
fn a_miscased_reserved_directory_fails() {
let mut rep = healthy();
rep.miscased_dir = Some(MiscasedDir {
found: "Global".into(),
reserved: "global",
});
let found = findings(&rep);
assert_eq!(
find(&found, "reserved directory").unwrap().level,
Level::Fail
);
assert_eq!(verdict(&found), exit::CONFIG);
}
#[test]
fn a_rejected_variable_fails_and_names_itself() {
let mut rep = healthy();
rep.rejected_vars = vec!["RECALL_PROJECT_KEY"];
let found = findings(&rep);
let f = find(&found, "rejected value").unwrap();
assert_eq!(f.level, Level::Fail);
assert!(f.detail.contains("RECALL_PROJECT_KEY"), "{}", f.detail);
}
#[test]
fn every_finding_that_is_not_ok_carries_a_fix() {
let mut rep = healthy();
rep.url_set = false;
rep.token_set = false;
rep.hooks_wired = false;
rep.remote_memory_dir_set = false;
rep.global_files = 3;
rep.machine_key = Some("machine:mbp".into());
rep.machine_files = 1;
rep.machine_linked = false;
rep.miscased_dir = Some(MiscasedDir {
found: "Global".into(),
reserved: "global",
});
rep.rejected_vars = vec!["RECALL_PROJECT_KEY"];
rep.unreadable_settings = vec![".claude/settings.json".into()];
for f in findings(&rep).iter().filter(|f| f.level != Level::Ok) {
assert!(
f.fix.as_deref().is_some_and(|s| !s.is_empty()),
"{} has no fix",
f.check
);
}
}
#[test]
fn an_unset_remote_memory_dir_fails_in_a_remote_session() {
let mut rep = healthy();
rep.remote_session = true;
rep.remote_memory_dir_set = false;
let found = findings(&rep);
let f = find(&found, "CLAUDE_CODE_REMOTE_MEMORY_DIR").unwrap();
assert_eq!(f.level, Level::Fail);
assert!(
f.fix.as_deref().unwrap().contains("/home/user/.claude"),
"{:?}",
f.fix
);
assert_eq!(verdict(&found), exit::CONFIG);
}
#[test]
fn an_unset_remote_memory_dir_is_fine_on_a_laptop() {
let mut rep = healthy();
rep.remote_session = false;
rep.remote_memory_dir_set = false;
let found = findings(&rep);
assert_eq!(
find(&found, "CLAUDE_CODE_REMOTE_MEMORY_DIR").unwrap().level,
Level::Ok
);
assert_eq!(verdict(&found), exit::OK);
}
#[test]
fn unwired_hooks_outside_a_git_repository_are_not_a_failure() {
let mut rep = healthy();
rep.in_git_repo = false;
rep.hooks_wired = false;
let found = findings(&rep);
assert_eq!(find(&found, "hooks").unwrap().level, Level::Ok);
assert_eq!(verdict(&found), exit::OK);
}
#[test]
fn unwired_hooks_inside_a_git_repository_still_fail() {
let mut rep = healthy();
rep.in_git_repo = true;
rep.hooks_wired = false;
let found = findings(&rep);
let f = find(&found, "hooks").unwrap();
assert_eq!(f.level, Level::Fail);
assert_eq!(f.fix.as_deref(), Some("recall init"));
assert_eq!(verdict(&found), exit::CONFIG);
}
fn with_worker(oldest: Option<String>, queued: u64) -> Report {
let mut rep = healthy();
rep.merge_worker = true;
rep.merge_queue = Some(recall_wire::QueueStatus {
queued,
leased: 0,
failed: 0,
oldest_queued_at: oldest,
});
rep
}
fn stamp_minutes_ago(minutes: i64) -> String {
let fmt = time::macros::format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
);
(time::OffsetDateTime::now_utc() - time::Duration::minutes(minutes))
.format(&fmt)
.unwrap()
}
#[test]
fn no_worker_no_queue_finding() {
assert!(find(&findings(&healthy()), "merge queue").is_none());
}
#[test]
fn a_queue_that_drains_is_fine() {
let found = findings(&with_worker(Some(stamp_minutes_ago(2)), 1));
assert_eq!(find(&found, "merge queue").unwrap().level, Level::Ok);
let found = findings(&with_worker(None, 0));
assert_eq!(
find(&found, "merge queue").unwrap().detail,
"nothing waiting"
);
}
#[test]
fn a_job_waiting_an_hour_is_a_warning() {
let found = findings(&with_worker(Some(stamp_minutes_ago(61)), 3));
let f = find(&found, "merge queue").unwrap();
assert_eq!(f.level, Level::Warn);
assert!(f.detail.starts_with("3 merges waiting"), "{}", f.detail);
assert!(f.fix.as_deref().unwrap().contains("recall-worker"));
assert_eq!(
verdict(&found),
exit::OK,
"a warning never fails the command"
);
}
#[test]
fn a_worker_that_stopped_asking_is_a_warning() {
let mut rep = with_worker(None, 0);
rep.merge_ready = true;
rep.merge_worker_seen_at = Some(stamp_minutes_ago(1));
assert!(worker_quiet(&rep).is_none());
assert!(find(&findings(&rep), "merge").is_none());
rep.merge_worker_seen_at = Some(stamp_minutes_ago(3));
assert!(worker_quiet(&rep).is_some());
let found = findings(&rep);
let f = find(&found, "merge").unwrap();
assert_eq!(f.level, Level::Warn);
assert!(f.detail.contains("has not asked for work"), "{}", f.detail);
assert!(f.fix.as_deref().unwrap().contains("logs recall-worker"));
rep.merge_worker = false;
assert!(worker_quiet(&rep).is_none());
}
#[test]
fn a_failed_merge_is_a_warning() {
let mut rep = with_worker(None, 0);
assert!(find(&findings(&rep), "failed merges").is_none());
rep.merge_queue.as_mut().unwrap().failed = 2;
let found = findings(&rep);
let f = find(&found, "failed merges").unwrap();
assert_eq!(f.level, Level::Warn);
assert!(f.detail.starts_with("2 merges failed"), "{}", f.detail);
assert!(f.fix.as_deref().unwrap().contains("/v1/jobs"));
rep.merge_worker = false;
assert!(find(&findings(&rep), "failed merges").is_some());
}
#[test]
fn a_worker_that_cannot_merge_names_the_worker() {
let mut rep = with_worker(None, 0);
rep.merge_ready = false;
let found = findings(&rep);
assert!(find(&found, "merge")
.unwrap()
.fix
.as_deref()
.unwrap()
.contains("recall-worker claude setup-token"));
}
#[test]
fn no_offbox_stamp_is_reported_as_nothing() {
let found = findings(&healthy());
assert!(find(&found, "off-box backup").is_none());
}
#[test]
fn a_recent_offbox_copy_is_fine() {
let mut rep = healthy();
rep.last_offbox_at = Some(stamp_days_ago(1));
let found = findings(&rep);
assert_eq!(find(&found, "off-box backup").unwrap().level, Level::Ok);
assert_eq!(verdict(&found), exit::OK);
}
#[test]
fn an_offbox_copy_that_stopped_is_reported_with_its_age() {
let mut rep = healthy();
rep.last_offbox_at = Some(stamp_days_ago(9));
let found = findings(&rep);
let f = find(&found, "off-box backup").unwrap();
assert_eq!(f.level, Level::Warn);
assert!(
f.detail.contains('9'),
"the age has to be in it: {}",
f.detail
);
assert_eq!(verdict(&found), exit::OK);
}
#[test]
fn a_single_missed_run_is_not_worth_reporting() {
let mut rep = healthy();
rep.last_offbox_at = Some(stamp_days_ago(1));
assert_eq!(
find(&findings(&rep), "off-box backup").unwrap().level,
Level::Ok
);
}
#[test]
fn an_unreadable_stamp_says_so_rather_than_guessing() {
let mut rep = healthy();
rep.last_offbox_at = Some("yesterday-ish".into());
let found = findings(&rep);
let f = find(&found, "off-box backup").unwrap();
assert_eq!(f.level, Level::Warn);
assert!(f.detail.contains("yesterday-ish"), "{}", f.detail);
}
fn stamp_days_ago(days: i64) -> String {
let fmt = time::macros::format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
);
(time::OffsetDateTime::now_utc() - time::Duration::days(days))
.format(&fmt)
.unwrap()
}
#[cfg(windows)]
#[test]
fn git_bash_is_found_on_this_runner() {
assert!(
git_bash_available(),
"expected Git for Windows on this runner; if the image changed, \
widen the search in git_bash_available()"
);
}
#[cfg(windows)]
#[test]
fn a_healthy_windows_report_includes_a_git_bash_finding() {
let found = findings(&healthy());
let f = find(&found, "Git Bash").expect("a Git Bash finding on Windows");
assert!(matches!(f.level, Level::Ok | Level::Fail));
}
}