use std::fs;
use std::io::{self, BufRead, Write as _};
use std::path::Path;
use crate::agent_cli::{self, AgentCli, McpReport, McpStatus};
use crate::claude_settings;
use crate::config::{self, Config, LlmSection, Provider};
use crate::error::RecallError;
use crate::paths;
use crate::rules_file;
use crate::theme::{BAD, BOLD, DIM, GOOD, RESET, WARN};
use crate::transcript::Source;
const MEMORY_TEMPLATE: &str = "# Memory\n\n\
<!-- recall-echo: Curated memory. Distilled facts, preferences, patterns. -->\n\
<!-- Keep under 200 lines. Only write confirmed, stable information. -->\n";
const ARCHIVE_TEMPLATE: &str = "# Conversation Archive\n\n\
| # | Date | Session | Topics | Messages | Duration |\n\
|---|------|---------|--------|----------|----------|\n";
const MODEL_DOWNLOAD_SIZE: &str = "~127 MB";
enum Status {
Created,
Exists,
Error,
}
fn print_status(status: Status, msg: &str) {
match status {
Status::Created => eprintln!(" {GOOD}✓{RESET} {msg}"),
Status::Exists => eprintln!(" {WARN}~{RESET} {msg}"),
Status::Error => eprintln!(" {BAD}✗{RESET} {msg}"),
}
}
fn notice_legacy_conversations(pulse_root: &Path, new_dir: &Path) {
let legacy = pulse_root.join("conversations");
let legacy_count = fs::read_dir(&legacy).map(|d| d.count()).unwrap_or(0);
let new_count = fs::read_dir(new_dir).map(|d| d.count()).unwrap_or(0);
if legacy_count > 0 && new_count == 0 {
print_status(
Status::Exists,
&format!(
"{legacy_count} archives in the legacy location {} — memory now lives at {}. \
Move them across to keep them searchable:",
legacy.display(),
new_dir.display()
),
);
eprintln!(" mv {}/* {}/", legacy.display(), new_dir.display());
eprintln!(
" {DIM}(and review {}/ARCHIVE.md against the one in memory/){RESET}",
pulse_root.display()
);
}
}
fn ensure_dir(path: &Path) {
use std::os::unix::fs::DirBuilderExt;
if !path.exists() {
if let Err(e) = fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(path)
{
print_status(
Status::Error,
&format!("Failed to create {}: {e}", path.display()),
);
}
}
}
fn write_if_not_exists(path: &Path, content: &str, label: &str) {
if path.exists() {
print_status(
Status::Exists,
&format!("{label} already exists — preserved"),
);
} else {
use std::os::unix::fs::OpenOptionsExt;
let written = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.and_then(|mut file| file.write_all(content.as_bytes()));
match written {
Ok(()) => print_status(Status::Created, &format!("Created {label}")),
Err(e) => print_status(Status::Error, &format!("Failed to create {label}: {e}")),
}
}
}
fn select_provider(
reader: &mut dyn BufRead,
detected: &[AgentCli],
interactive: bool,
) -> Option<Provider> {
match detected {
[only] => {
print_status(
Status::Created,
&format!("found {only} — using it for extraction"),
);
Some(only.provider())
}
[] => {
eprintln!(
"\n {WARN}~{RESET} No agent CLI found. Extraction needs a model provider — \
{BOLD}ollama{RESET} is the free, local option."
);
prompt_any_provider(reader, interactive)
}
several => prompt_installed_cli(reader, several, interactive),
}
}
fn default_cli(detected: &[AgentCli]) -> AgentCli {
let running_under = agent_cli::current().filter(|cli| detected.contains(cli));
running_under
.or_else(|| {
detected
.contains(&AgentCli::ClaudeCode)
.then_some(AgentCli::ClaudeCode)
})
.or_else(|| detected.first().copied())
.unwrap_or(AgentCli::ClaudeCode)
}
fn prompt_installed_cli(
reader: &mut dyn BufRead,
detected: &[AgentCli],
interactive: bool,
) -> Option<Provider> {
let default = default_cli(detected);
if !interactive {
print_status(
Status::Created,
&format!(
"{} agent CLIs found — using {default} for extraction",
detected.len()
),
);
return Some(default.provider());
}
let default_index = detected.iter().position(|cli| *cli == default).unwrap_or(0) + 1;
eprintln!("\n{BOLD}Which CLI should recall-echo use to extract knowledge?{RESET}");
for (index, cli) in detected.iter().enumerate() {
let note = if *cli == default {
if agent_cli::current() == Some(*cli) {
"— you're running under it (default)"
} else {
"— (default)"
}
} else {
""
};
eprintln!(
" {BOLD}{}{RESET}) {:<12}{DIM}{note}{RESET}",
index + 1,
cli.label()
);
}
eprintln!(" {BOLD}o{RESET}) other {DIM}— Claude API, Ollama, or decide later{RESET}");
eprint!("\n Choice [{default_index}]: ");
io::stderr().flush().ok();
let mut input = String::new();
if reader.read_line(&mut input).is_err() {
return Some(default.provider());
}
let answer = input.trim().to_lowercase();
if answer.is_empty() {
return Some(default.provider());
}
if answer == "o" || answer == "other" {
return prompt_any_provider(reader, interactive);
}
if let Some(cli) = answer
.parse::<usize>()
.ok()
.and_then(|n| detected.get(n.wrapping_sub(1)))
{
return Some(cli.provider());
}
if let Some(cli) = detected.iter().find(|cli| cli.label() == answer) {
return Some(cli.provider());
}
eprintln!(" {WARN}~{RESET} Unknown choice, defaulting to {default}");
Some(default.provider())
}
fn prompt_any_provider(reader: &mut dyn BufRead, interactive: bool) -> Option<Provider> {
if !interactive {
return Some(Provider::Anthropic);
}
eprintln!("\n{BOLD}LLM provider for entity extraction:{RESET}");
eprintln!(" {BOLD}1{RESET}) anthropic {DIM}— Claude API (default){RESET}");
eprintln!(" {BOLD}2{RESET}) ollama {DIM}— Local models via Ollama, free{RESET}");
eprintln!(
" {BOLD}3{RESET}) claude-code {DIM}— Spawns your `claude` CLI (subscription){RESET}"
);
eprintln!(
" {BOLD}4{RESET}) gemini {DIM}— Spawns your `gemini` CLI (subscription){RESET}"
);
eprintln!(" {BOLD}5{RESET}) grok {DIM}— Spawns your `grok` CLI (subscription){RESET}");
eprintln!(" {BOLD}6{RESET}) codex {DIM}— Spawns your `codex` CLI (subscription){RESET}");
eprintln!(
" {BOLD}7{RESET}) skip {DIM}— Configure later with `recall-echo config`{RESET}"
);
eprint!("\n Choice [1]: ");
io::stderr().flush().ok();
let mut input = String::new();
if reader.read_line(&mut input).is_err() {
return None;
}
match input.trim() {
"" | "1" | "anthropic" => Some(Provider::Anthropic),
"2" | "ollama" => Some(Provider::Openai),
"3" | "claude-code" => Some(Provider::ClaudeCode),
"4" | "gemini" => Some(Provider::Gemini),
"5" | "grok" => Some(Provider::Grok),
"6" | "codex" => Some(Provider::Codex),
"7" | "skip" => None,
_ => {
eprintln!(" {WARN}~{RESET} Unknown choice, defaulting to anthropic");
Some(Provider::Anthropic)
}
}
}
fn configure_llm(
reader: &mut dyn BufRead,
memory_dir: &Path,
detected: &[AgentCli],
interactive: bool,
) -> Option<Provider> {
if config::exists(memory_dir) {
print_status(
Status::Exists,
".recall-echo.toml already exists — preserved",
);
return Some(config::load(memory_dir).llm.provider);
}
let Some(provider) = select_provider(reader, detected, interactive) else {
print_status(
Status::Exists,
"Skipped LLM config — run `recall-echo config set provider <name>` later",
);
return None;
};
let cfg = Config {
llm: LlmSection {
provider: provider.clone(),
..LlmSection::default()
},
..Config::default()
};
match config::save(memory_dir, &cfg) {
Ok(()) => {
print_status(
Status::Created,
&format!(
"Created .recall-echo.toml (provider: {})",
label_of(&provider)
),
);
Some(provider)
}
Err(e) => {
print_status(Status::Error, &format!("Failed to write config: {e}"));
None
}
}
}
fn label_of(provider: &Provider) -> String {
match provider {
Provider::Openai => "ollama (openai-compat)".to_string(),
other => other.to_string(),
}
}
fn extraction_line(provider: &Provider) -> String {
match provider {
Provider::Anthropic => "anthropic (Claude API — set ANTHROPIC_API_KEY)".into(),
Provider::Openai => "ollama (local models — free)".into(),
Provider::Cli => "custom CLI (from `[llm.cli]`)".into(),
cli => format!("{cli} (your subscription — no API billing)"),
}
}
fn init_graph(runtime: &tokio::runtime::Runtime, memory_dir: &Path) {
let graph_dir = memory_dir.join("graph");
if graph_dir.exists() {
print_status(Status::Exists, "graph/ already exists — preserved");
return;
}
match runtime.block_on(crate::graph::GraphMemory::open(&graph_dir)) {
Ok(_) => print_status(Status::Created, "Created graph/ (SurrealDB)"),
Err(e) => print_status(Status::Error, &format!("Failed to init graph: {e}")),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum WarmOutcome {
Ready,
Skipped(&'static str),
Failed(String),
}
fn warm_embedding_model(memory_dir: &Path, roots: &paths::ConfigRoots) -> WarmOutcome {
if paths::is_build_dir(roots.recall_bin()) {
return WarmOutcome::Skipped("running from a build directory");
}
if !roots.spawns_agents() {
return WarmOutcome::Skipped("sandboxed configuration roots");
}
let models_dir = memory_dir.join("graph").join("models");
if let Err(e) = fs::create_dir_all(&models_dir) {
return WarmOutcome::Failed(format!("could not create {}: {e}", models_dir.display()));
}
let cached = fs::read_dir(&models_dir).is_ok_and(|mut entries| entries.next().is_some());
if cached {
eprintln!(" {DIM}… loading the embedding model{RESET}");
} else {
eprintln!(
" {DIM}… downloading the embedding model ({MODEL_DOWNLOAD_SIZE}, once) — \
everything else is already set up, Ctrl-C is safe{RESET}"
);
}
match crate::graph::embed::FastEmbedder::new(&models_dir) {
Ok(_) => WarmOutcome::Ready,
Err(e) => WarmOutcome::Failed(e.to_string()),
}
}
fn configure_claude_code(
roots: &paths::ConfigRoots,
pulse_root: &Path,
reader: &mut dyn BufRead,
interactive: bool,
env_disable: Option<&str>,
) {
let target = match claude_settings::claude_code_target(roots, pulse_root) {
Ok(target) => target,
Err(claude_settings::Skip::NoClaudeCode) => return,
Err(skip) => {
print_status(
Status::Exists,
&format!("Skipped Claude Code hooks, rules file and auto-memory — {skip}"),
);
if skip != claude_settings::Skip::BuildDir {
retire_rules(roots.claude_dir(), pulse_root, &skip);
}
return;
}
};
let settings_path = target.settings_path();
let mut edit = match claude_settings::open_settings(&settings_path) {
claude_settings::OpenSettings::Edit(edit) => Some(edit),
claude_settings::OpenSettings::Symlink => {
advise_symlinked_settings(&settings_path, roots, pulse_root);
None
}
claude_settings::OpenSettings::Refused(why) => {
print_status(
Status::Error,
&format!("{why} — hooks and auto-memory left as is"),
);
None
}
};
let hooks_changed = edit
.as_mut()
.is_some_and(|edit| stage_hooks(&mut edit.value, roots, pulse_root));
let mut auto_memory_off = false;
match target.memory_steps() {
Err(skip) => {
print_status(
Status::Exists,
&format!("Skipped rules file and auto-memory — {skip}"),
);
retire_rules(Some(target.claude_dir()), pulse_root, skip);
}
Ok(()) => {
configure_rules(&target, pulse_root);
if let Some(edit) = edit.as_mut() {
auto_memory_off = stage_auto_memory(
&mut edit.value,
&settings_path,
reader,
interactive,
env_disable,
) == AutoMemoryStep::TurnedOff;
}
}
}
if let Some(edit) = edit {
commit_settings(edit, hooks_changed, auto_memory_off, roots, pulse_root);
}
}
fn stage_hooks(
settings: &mut serde_json::Value,
roots: &paths::ConfigRoots,
pulse_root: &Path,
) -> bool {
match claude_settings::apply_hooks(settings, pulse_root, roots.recall_bin()) {
Ok(change) => {
for note in &change.notes {
print_status(Status::Exists, note);
}
if !change.changed {
print_status(Status::Exists, "Hooks already configured in settings.json");
}
change.changed
}
Err(why) => {
print_status(Status::Error, &why);
false
}
}
}
fn commit_settings(
edit: claude_settings::SettingsEdit,
hooks_changed: bool,
auto_memory_off: bool,
roots: &paths::ConfigRoots,
pulse_root: &Path,
) {
if !hooks_changed && !auto_memory_off {
return;
}
let reapply = |settings: &mut serde_json::Value| {
if hooks_changed {
claude_settings::apply_hooks(settings, pulse_root, roots.recall_bin())?;
}
if auto_memory_off {
claude_settings::disable_auto_memory(settings);
}
Ok(())
};
match edit.commit(reapply) {
Ok(committed) => {
if committed == claude_settings::Committed::WrittenOverChange {
print_status(
Status::Exists,
"settings.json changed while init was running — kept its new content \
and applied this run's changes to it",
);
}
if hooks_changed {
print_status(
Status::Created,
"Configured SessionStart + SessionEnd + PreCompact hooks in settings.json",
);
}
if auto_memory_off {
print_status(
Status::Created,
"Turned off Claude Code's auto-memory — MEMORY.md here is the one curated \
store",
);
}
}
Err(why) => print_status(Status::Error, &why),
}
}
fn advise_symlinked_settings(settings_path: &Path, roots: &paths::ConfigRoots, pulse_root: &Path) {
let mut wanted = serde_json::json!({});
let hooks = claude_settings::apply_hooks(&mut wanted, pulse_root, roots.recall_bin())
.ok()
.and_then(|_| serde_json::to_string_pretty(&wanted["hooks"]).ok());
print_status(
Status::Exists,
&format!(
"{} is a symlink — not replaced. Add the recall-echo hooks to the file it points \
at yourself, and \"autoMemoryEnabled\": false to keep one curated store:",
settings_path.display()
),
);
if let Some(hooks) = hooks {
for line in format!("\"hooks\": {hooks}").lines() {
eprintln!(" {line}");
}
}
}
fn retire_rules(claude_dir: Option<&Path>, pulse_root: &Path, skip: &claude_settings::Skip) {
let because = format!("this run skips the rules file: {skip}");
if let Some(message) =
claude_dir.and_then(|dir| rules_file::retire_managed(dir, pulse_root, &because))
{
print_status(Status::Exists, &message);
}
}
fn configure_rules(target: &claude_settings::ClaudeTarget, pulse_root: &Path) {
let report = rules_file::install(target.claude_dir(), pulse_root);
for warning in &report.warnings {
print_status(Status::Exists, warning);
}
match report.outcome {
rules_file::Outcome::Written(path) => print_status(
Status::Created,
&format!(
"Wrote {} — MEMORY.md now loads in every Claude Code session",
path.display()
),
),
rules_file::Outcome::Unchanged(path) => print_status(
Status::Exists,
&format!("{} already up to date", path.display()),
),
rules_file::Outcome::UnmanagedImports(path) => print_status(
Status::Exists,
&format!(
"{} is not managed by recall-echo but already imports MEMORY.md — left as is",
path.display()
),
),
rules_file::Outcome::Unmanaged { path, import_line } => {
print_status(
Status::Exists,
&format!(
"{} is not managed by recall-echo — left as is. For MEMORY.md to \
load in every session it needs this line:",
path.display()
),
);
eprintln!(" {import_line}");
}
rules_file::Outcome::Refused(why) | rules_file::Outcome::Failed(why) => {
print_status(Status::Error, &format!("Rules file not written: {why}"));
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum AutoMemoryStep {
AlreadyOff,
Noticed,
Declined,
TurnedOff,
}
fn stage_auto_memory(
settings: &mut serde_json::Value,
settings_path: &Path,
reader: &mut dyn BufRead,
interactive: bool,
env_disable: Option<&str>,
) -> AutoMemoryStep {
if !claude_settings::auto_memory_on(settings, env_disable) {
return AutoMemoryStep::AlreadyOff;
}
let what = "Claude Code's built-in auto-memory is on: a second curated store that \
recall-echo does not read";
let not_moved = "Notes already under ~/.claude/projects/*/memory/ are not moved.";
if !interactive {
print_status(
Status::Exists,
&format!(
"{what}. Set \"autoMemoryEnabled\": false in {} to keep one store. {not_moved}",
settings_path.display()
),
);
return AutoMemoryStep::Noticed;
}
eprintln!("\n{BOLD}{what}.{RESET}");
eprintln!(" {DIM}{not_moved}{RESET}");
if !ask_yes_default(reader, "Turn it off?") {
print_status(Status::Exists, "Left Claude Code's auto-memory on");
return AutoMemoryStep::Declined;
}
claude_settings::disable_auto_memory(settings);
AutoMemoryStep::TurnedOff
}
fn ask_yes_default(reader: &mut dyn BufRead, question: &str) -> bool {
eprint!(" {question} [Y/n] ");
let mut answer = String::new();
match reader.read_line(&mut answer) {
Ok(0) | Err(_) => false,
Ok(_) => matches!(
answer.trim().to_ascii_lowercase().as_str(),
"" | "y" | "yes"
),
}
}
fn register_mcp_clients(
runtime: &tokio::runtime::Runtime,
detected: &[AgentCli],
pulse_root: &Path,
roots: &paths::ConfigRoots,
) -> Vec<McpReport> {
if detected.is_empty() {
return Vec::new();
}
if !roots.spawns_agents() {
print_status(
Status::Exists,
"Skipped MCP registration — sandboxed configuration roots",
);
return Vec::new();
}
let exe = roots.recall_bin().to_string();
if paths::is_build_dir(&exe) {
print_status(
Status::Exists,
"Skipped MCP registration — running from a build directory",
);
return Vec::new();
}
let root = fs::canonicalize(pulse_root).unwrap_or_else(|_| pulse_root.to_path_buf());
let reports: Vec<McpReport> = runtime.block_on(async {
let mut reports = Vec::with_capacity(detected.len());
for cli in detected {
reports.push(agent_cli::register_mcp(*cli, &exe, &root, roots).await);
}
reports
});
for report in &reports {
match &report.status {
McpStatus::Registered => print_status(
Status::Created,
&format!("Registered MCP server with {}", report.cli),
),
McpStatus::AlreadyRegistered => print_status(
Status::Exists,
&format!("MCP server already registered with {}", report.cli),
),
McpStatus::Failed(detail) => {
print_status(
Status::Error,
&format!("Could not register MCP with {}: {detail}", report.cli),
);
eprintln!(" {DIM}run it yourself: {}{RESET}", report.command);
}
}
}
reports
}
struct Summary {
memory_dir: std::path::PathBuf,
provider: Option<Provider>,
capture: Vec<Source>,
mcp: Vec<McpReport>,
embedder: WarmOutcome,
}
impl Summary {
fn mcp_ready(&self) -> Vec<&'static str> {
self.mcp
.iter()
.filter(|report| !matches!(report.status, McpStatus::Failed(_)))
.map(|report| report.cli.label())
.collect()
}
}
fn print_summary(summary: &Summary) {
eprintln!("\n{BOLD}Setup complete.{RESET}\n");
print_status(
Status::Created,
&format!("memory initialised at {}", summary.memory_dir.display()),
);
match &summary.provider {
Some(provider) => print_status(
Status::Created,
&format!("extraction: {}", extraction_line(provider)),
),
None => print_status(
Status::Exists,
"extraction: not configured — `recall-echo config set provider <name>`",
),
}
if summary.capture.is_empty() {
print_status(
Status::Exists,
"capture: no agent CLI has recorded sessions here yet",
);
} else {
let names: Vec<&str> = summary.capture.iter().map(Source::as_str).collect();
print_status(Status::Created, &format!("capture: {}", names.join(", ")));
}
let ready = summary.mcp_ready();
if !ready.is_empty() {
print_status(
Status::Created,
&format!("MCP registered: {}", ready.join(", ")),
);
}
match &summary.embedder {
WarmOutcome::Ready => print_status(Status::Created, "embedding model ready"),
WarmOutcome::Skipped(reason) => print_status(
Status::Exists,
&format!("embedding model not warmed ({reason}) — downloads on first use"),
),
WarmOutcome::Failed(detail) => print_status(
Status::Exists,
&format!("embedding model not downloaded ({detail}) — retries on first use"),
),
}
eprintln!("\n {BOLD}Your next session will be remembered.{RESET}\n");
eprintln!(" {DIM}recall-echo status — is it healthy, what has it got{RESET}");
eprintln!(" {DIM}recall-echo config show — what it decided{RESET}");
eprintln!();
}
pub fn run(pulse_root: &Path) -> Result<(), RecallError> {
let stdin = io::stdin();
let mut reader = stdin.lock();
run_with_reader(pulse_root, &mut reader)
}
pub fn run_with_reader(pulse_root: &Path, reader: &mut dyn BufRead) -> Result<(), RecallError> {
run_with(pulse_root, reader, &paths::ConfigRoots::from_env())
}
pub fn run_with(
pulse_root: &Path,
reader: &mut dyn BufRead,
roots: &paths::ConfigRoots,
) -> Result<(), RecallError> {
if !pulse_root.exists() {
return Err(RecallError::NotInitialized(format!(
"Directory not found: {}\n Create the directory first, or run from a valid path.",
pulse_root.display()
)));
}
eprintln!("\n{BOLD}recall-echo{RESET} — initializing memory system\n");
let memory_dir = pulse_root.join("memory");
let conversations_dir = memory_dir.join("conversations");
ensure_dir(&memory_dir);
ensure_dir(&conversations_dir);
notice_legacy_conversations(pulse_root, &conversations_dir);
match roots.persist_pulse_root(pulse_root) {
Ok(paths::PersistOutcome::Written(file)) => print_status(
Status::Created,
&format!("Pulse root persisted to {}", file.display()),
),
Ok(paths::PersistOutcome::Skipped(why)) => print_status(
Status::Exists,
&format!("Skipped persisting the pulse root — {why}"),
),
Err(e) => print_status(Status::Error, &format!("Could not persist pulse root: {e}")),
}
write_if_not_exists(&memory_dir.join("MEMORY.md"), MEMORY_TEMPLATE, "MEMORY.md");
let memory_md = memory_dir.join("MEMORY.md");
if let Some(content) = fs::metadata(&memory_md)
.is_ok_and(|meta| meta.is_file())
.then(|| fs::read_to_string(&memory_md).ok())
.flatten()
{
print_status(
Status::Exists,
&format!(
"MEMORY.md is {} lines, {} — loaded into every Claude Code session",
content.lines().count(),
crate::distill::size_summary(&content)
),
);
if let Some(warning) = rules_file::hidden_import_warning(&content) {
print_status(Status::Exists, &warning);
}
}
write_if_not_exists(&memory_dir.join("EPHEMERAL.md"), "", "EPHEMERAL.md");
write_if_not_exists(
&memory_dir.join("ARCHIVE.md"),
ARCHIVE_TEMPLATE,
"ARCHIVE.md",
);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
let runtime = match runtime {
Ok(runtime) => Some(runtime),
Err(e) => {
print_status(Status::Error, &format!("Failed to start runtime: {e}"));
None
}
};
if let Some(runtime) = &runtime {
init_graph(runtime, &memory_dir);
}
let detected = agent_cli::installed();
let provider = configure_llm(reader, &memory_dir, &detected, roots.interactive());
configure_claude_code(
roots,
pulse_root,
reader,
roots.interactive(),
roots.auto_memory_env(),
);
let mcp = match &runtime {
Some(runtime) => register_mcp_clients(runtime, &detected, pulse_root, roots),
None => Vec::new(),
};
let embedder = warm_embedding_model(&memory_dir, roots);
print_summary(&Summary {
memory_dir,
provider,
capture: agent_cli::capturing(),
mcp,
embedder,
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use std::path::PathBuf;
use std::time::SystemTime;
struct Sandbox {
dir: tempfile::TempDir,
roots: paths::ConfigRoots,
}
impl Sandbox {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let entity = dir.path().join("entity");
fs::create_dir_all(&entity).unwrap();
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&entity, fs::Permissions::from_mode(0o755)).unwrap();
}
let roots = paths::ConfigRoots::sandboxed(dir.path()).unwrap();
Self { dir, roots }
}
fn pulse_root(&self) -> PathBuf {
self.dir.path().join("entity")
}
fn init(&self, input: &str) -> Result<(), RecallError> {
let mut reader = Cursor::new(input.as_bytes());
run_with(&self.pulse_root(), &mut reader, &self.roots)
}
}
#[test]
fn init_creates_directories_and_files() {
let sandbox = Sandbox::new();
sandbox.init("skip\n").unwrap();
let root = sandbox.pulse_root();
assert!(root.join("memory/MEMORY.md").exists());
assert!(root.join("memory/EPHEMERAL.md").exists());
assert!(root.join("memory/ARCHIVE.md").exists());
assert!(root.join("memory/conversations").exists());
}
#[test]
fn init_creates_the_store_owner_only() {
use std::os::unix::fs::PermissionsExt;
let sandbox = Sandbox::new();
sandbox.init("skip\n").unwrap();
let memory = sandbox.pulse_root().join("memory");
let mode = |p: PathBuf| fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(memory.clone()), 0o700);
assert_eq!(mode(memory.join("conversations")), 0o700);
for file in ["MEMORY.md", "EPHEMERAL.md", "ARCHIVE.md"] {
assert_eq!(mode(memory.join(file)), 0o600, "{file}");
}
assert!(
sandbox_rules(&sandbox).exists(),
"the rules step accepted it"
);
}
#[test]
fn init_persists_the_pulse_root_inside_the_sandbox() {
let sandbox = Sandbox::new();
sandbox.init("skip\n").unwrap();
let persisted = sandbox.roots.pulse_root_file().expect("a destination");
let pinned = fs::read_to_string(persisted).expect("persisted inside the sandbox");
assert_eq!(
pinned.trim(),
fs::canonicalize(sandbox.pulse_root())
.unwrap()
.to_string_lossy()
);
}
#[test]
fn init_is_idempotent() {
let sandbox = Sandbox::new();
let root = sandbox.pulse_root();
sandbox.init("skip\n").unwrap();
fs::write(root.join("memory/MEMORY.md"), "custom content").unwrap();
sandbox.init("skip\n").unwrap();
let content = fs::read_to_string(root.join("memory/MEMORY.md")).unwrap();
assert_eq!(content, "custom content");
}
#[test]
fn a_second_init_preserves_the_configured_provider() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let memory_dir = root.join("memory");
fs::create_dir_all(&memory_dir).unwrap();
let chosen = configure_llm(
&mut Cursor::new(b"" as &[u8]),
&memory_dir,
&[AgentCli::Grok],
false,
);
assert_eq!(chosen, Some(Provider::Grok));
let again = configure_llm(
&mut Cursor::new(b"" as &[u8]),
&memory_dir,
&[AgentCli::ClaudeCode, AgentCli::Codex],
false,
);
assert_eq!(again, Some(Provider::Grok));
}
#[test]
fn init_fails_if_root_missing() {
let sandbox = Sandbox::new();
let mut reader = Cursor::new(b"" as &[u8]);
let result = run_with(Path::new("/nonexistent/path"), &mut reader, &sandbox.roots);
assert!(result.is_err());
}
#[test]
fn a_single_installed_cli_is_chosen_without_asking() {
let mut reader = Cursor::new(b"" as &[u8]);
assert_eq!(
select_provider(&mut reader, &[AgentCli::Codex], false),
Some(Provider::Codex)
);
assert_eq!(reader.position(), 0, "nothing should have been read");
}
#[test]
fn several_installed_clis_default_without_blocking() {
let mut reader = Cursor::new(b"" as &[u8]);
let chosen = select_provider(&mut reader, &[AgentCli::Grok, AgentCli::Codex], false);
assert_eq!(chosen, Some(Provider::Grok));
}
#[test]
fn the_default_prefers_claude_code_over_install_order() {
assert_eq!(
default_cli(&[AgentCli::Codex, AgentCli::ClaudeCode]),
AgentCli::ClaudeCode
);
assert_eq!(
default_cli(&[AgentCli::Gemini, AgentCli::Grok]),
AgentCli::Gemini
);
assert_eq!(default_cli(&[]), AgentCli::ClaudeCode);
}
#[test]
fn no_installed_cli_falls_back_to_the_full_menu() {
let mut reader = Cursor::new(b"" as &[u8]);
assert_eq!(
select_provider(&mut reader, &[], false),
Some(Provider::Anthropic)
);
}
#[test]
fn the_summary_names_the_cost_of_each_provider() {
assert!(extraction_line(&Provider::Grok).contains("no API billing"));
assert!(extraction_line(&Provider::Anthropic).contains("ANTHROPIC_API_KEY"));
assert!(extraction_line(&Provider::Openai).contains("free"));
}
#[test]
fn the_summary_lists_only_the_clients_that_registered() {
let summary = Summary {
memory_dir: std::path::PathBuf::from("/tmp/memory"),
provider: Some(Provider::Grok),
capture: vec![Source::Grok],
mcp: vec![
McpReport {
cli: AgentCli::ClaudeCode,
status: McpStatus::Registered,
command: String::new(),
},
McpReport {
cli: AgentCli::Grok,
status: McpStatus::AlreadyRegistered,
command: String::new(),
},
McpReport {
cli: AgentCli::Gemini,
status: McpStatus::Failed("no".into()),
command: String::new(),
},
],
embedder: WarmOutcome::Ready,
};
assert_eq!(summary.mcp_ready(), ["claude-code", "grok"]);
}
#[test]
fn hooks_are_skipped_when_claude_code_is_absent() {
let sandbox = Sandbox::new();
let roots = sandbox.roots.clone().without_claude_code();
assert_eq!(
claude_settings::claude_code_target(&roots, &sandbox.pulse_root()).err(),
Some(claude_settings::Skip::NoClaudeCode)
);
configure_claude_code(
&roots,
&sandbox.pulse_root(),
&mut Cursor::new(Vec::new()),
true,
None,
);
assert!(!sandbox.dir.path().join(".claude/settings.json").exists());
}
fn sandbox_rules(sandbox: &Sandbox) -> PathBuf {
sandbox
.roots
.claude_dir()
.unwrap()
.join("rules")
.join(rules_file::FILE_NAME)
}
#[test]
fn init_writes_rules_file_in_sandbox() {
let sandbox = Sandbox::new();
sandbox.init("skip\n").unwrap();
let rules = fs::read_to_string(sandbox_rules(&sandbox)).expect("rules file in the sandbox");
assert_eq!(rules.lines().next(), Some(rules_file::MARKER));
let root = fs::canonicalize(sandbox.pulse_root()).unwrap();
let line = rules_file::import_line(&root).unwrap();
let imports: Vec<&str> = rules.lines().filter(|l| l.starts_with('@')).collect();
assert_eq!(imports, [line.as_str()]);
}
#[test]
fn a_second_init_leaves_the_rules_file_as_it_was() {
let sandbox = Sandbox::new();
sandbox.init("skip\n").unwrap();
let first = fs::read(sandbox_rules(&sandbox)).unwrap();
sandbox.init("skip\n").unwrap();
assert_eq!(fs::read(sandbox_rules(&sandbox)).unwrap(), first);
}
#[test]
fn init_skips_rules_for_pulse_root() {
let sandbox = Sandbox::new();
fs::write(sandbox.pulse_root().join("pulse-null.toml"), "[pulse]\n").unwrap();
sandbox.init("skip\n").unwrap();
assert!(!sandbox_rules(&sandbox).exists());
}
#[test]
fn a_later_pulse_home_removes_the_managed_rules_file_for_this_store() {
let sandbox = Sandbox::new();
sandbox.init("skip\n").unwrap();
assert!(sandbox_rules(&sandbox).exists());
let pulse = sandbox
.dir
.path()
.join("pulse-null")
.join("pulses")
.join("echo");
fs::create_dir_all(&pulse).unwrap();
fs::write(pulse.join("pulse-null.toml"), "[pulse]\n").unwrap();
sandbox.init("skip\n").unwrap();
assert!(!sandbox_rules(&sandbox).exists());
}
#[test]
fn init_skips_every_claude_code_step_when_the_home_holds_pulses() {
let sandbox = Sandbox::new();
let pulse = sandbox.dir.path().join("pulse-null").join("echo");
fs::create_dir_all(&pulse).unwrap();
fs::write(pulse.join("pulse-null.toml"), "[pulse]\n").unwrap();
let settings_path = seeded_settings(&sandbox);
let mut reader = Cursor::new(b"y\n".to_vec());
configure_claude_code(
&sandbox.roots,
&sandbox.pulse_root(),
&mut reader,
true,
None,
);
assert!(!sandbox_rules(&sandbox).exists());
let settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(settings_path).unwrap()).unwrap();
assert!(settings.get("autoMemoryEnabled").is_none(), "{settings}");
assert!(settings.get("hooks").is_none(), "{settings}");
assert_eq!(reader.position(), 0, "nothing asked");
}
#[test]
fn init_leaves_an_unmanaged_rules_file_untouched() {
let sandbox = Sandbox::new();
let rules = sandbox_rules(&sandbox);
fs::create_dir_all(rules.parent().unwrap()).unwrap();
fs::write(&rules, "# hand-written\n").unwrap();
sandbox.init("skip\n").unwrap();
assert_eq!(fs::read_to_string(&rules).unwrap(), "# hand-written\n");
}
#[test]
fn rules_are_skipped_when_claude_code_is_absent() {
let sandbox = Sandbox::new();
let roots = sandbox.roots.clone().without_claude_code();
configure_claude_code(
&roots,
&sandbox.pulse_root(),
&mut Cursor::new(Vec::new()),
true,
None,
);
assert!(!sandbox.dir.path().join(".claude/rules").exists());
}
fn settings_file(sandbox: &Sandbox) -> PathBuf {
sandbox.roots.claude_dir().unwrap().join("settings.json")
}
fn settled_settings(sandbox: &Sandbox, json: &str) -> PathBuf {
let path = settings_file(sandbox);
fs::write(&path, json).unwrap();
configure_claude_code(
&sandbox.roots,
&sandbox.pulse_root(),
&mut Cursor::new(Vec::new()),
false,
None,
);
path
}
fn seeded_settings(sandbox: &Sandbox) -> PathBuf {
settled_settings(
sandbox,
r#"{"model": "opus", "permissions": {"allow": ["Bash(ls)"]}}"#,
)
}
fn ask_auto_memory_with(
sandbox: &Sandbox,
answer: &str,
interactive: bool,
env_disable: Option<&str>,
) -> Cursor<Vec<u8>> {
let mut reader = Cursor::new(answer.as_bytes().to_vec());
configure_claude_code(
&sandbox.roots,
&sandbox.pulse_root(),
&mut reader,
interactive,
env_disable,
);
reader
}
fn ask_auto_memory(sandbox: &Sandbox, answer: &str, interactive: bool) -> Cursor<Vec<u8>> {
ask_auto_memory_with(sandbox, answer, interactive, None)
}
#[test]
fn auto_memory_yes_writes_false_preserving_keys() {
let sandbox = Sandbox::new();
let path = seeded_settings(&sandbox);
ask_auto_memory(&sandbox, "y\n", true);
let settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(settings["autoMemoryEnabled"], serde_json::json!(false));
assert_eq!(settings["model"], "opus");
assert_eq!(settings["permissions"]["allow"][0], "Bash(ls)");
}
#[test]
fn auto_memory_enter_defaults_to_yes() {
let sandbox = Sandbox::new();
let path = seeded_settings(&sandbox);
ask_auto_memory(&sandbox, "\n", true);
assert!(fs::read_to_string(&path)
.unwrap()
.contains("\"autoMemoryEnabled\": false"));
}
#[test]
fn auto_memory_no_or_end_of_input_leaves_settings_byte_identical() {
for answer in ["n\n", "no\n", ""] {
let sandbox = Sandbox::new();
let path = seeded_settings(&sandbox);
let before = fs::read(&path).unwrap();
ask_auto_memory(&sandbox, answer, true);
assert_eq!(fs::read(&path).unwrap(), before, "answer {answer:?}");
}
}
#[test]
fn auto_memory_non_interactive_never_writes_or_reads() {
let sandbox = Sandbox::new();
let path = seeded_settings(&sandbox);
let before = fs::read(&path).unwrap();
let reader = ask_auto_memory(&sandbox, "y\n", false);
assert_eq!(fs::read(&path).unwrap(), before);
assert_eq!(reader.position(), 0, "nothing asked");
}
#[test]
fn a_non_interactive_run_takes_the_notice_branch() {
let path = Path::new("/sandbox/.claude/settings.json");
let step = |settings: serde_json::Value, interactive: bool, answer: &str| {
let mut settings = settings;
let mut reader = Cursor::new(answer.as_bytes().to_vec());
stage_auto_memory(&mut settings, path, &mut reader, interactive, None)
};
assert_eq!(
step(serde_json::json!({}), false, "y\n"),
AutoMemoryStep::Noticed
);
assert_eq!(
step(serde_json::json!({}), true, "n\n"),
AutoMemoryStep::Declined
);
assert_eq!(
step(serde_json::json!({}), true, "y\n"),
AutoMemoryStep::TurnedOff
);
assert_eq!(
step(serde_json::json!({"autoMemoryEnabled": false}), false, ""),
AutoMemoryStep::AlreadyOff
);
}
#[test]
fn auto_memory_already_off_is_not_asked() {
let sandbox = Sandbox::new();
let path = settled_settings(&sandbox, r#"{"autoMemoryEnabled": false}"#);
let before = fs::read(&path).unwrap();
let reader = ask_auto_memory(&sandbox, "y\n", true);
assert_eq!(fs::read(&path).unwrap(), before);
assert_eq!(reader.position(), 0, "nothing asked");
}
#[test]
fn auto_memory_off_by_environment_is_not_asked() {
let sandbox = Sandbox::new();
let path = seeded_settings(&sandbox);
let before = fs::read(&path).unwrap();
let reader = ask_auto_memory_with(&sandbox, "y\n", true, Some("1"));
assert_eq!(fs::read(&path).unwrap(), before);
assert_eq!(reader.position(), 0, "nothing asked");
}
#[test]
fn one_run_writes_settings_once_and_backs_up_the_original() {
let sandbox = Sandbox::new();
let path = settings_file(&sandbox);
let original = r#"{"model": "opus"}"#;
fs::write(&path, original).unwrap();
ask_auto_memory(&sandbox, "y\n", true);
let settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert!(settings["hooks"]["SessionStart"].is_array(), "{settings}");
assert_eq!(settings["autoMemoryEnabled"], serde_json::json!(false));
assert_eq!(settings["model"], "opus");
assert_eq!(
fs::read_to_string(path.with_extension("json.bak")).unwrap(),
original
);
}
struct EditsDuringPrompt {
answer: Cursor<Vec<u8>>,
path: PathBuf,
edit: Option<&'static str>,
}
impl std::io::Read for EditsDuringPrompt {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.answer.read(buf)
}
}
impl BufRead for EditsDuringPrompt {
fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
if let Some(edit) = self.edit.take() {
fs::write(&self.path, edit).unwrap();
}
self.answer.fill_buf()
}
fn consume(&mut self, amount: usize) {
self.answer.consume(amount);
}
}
fn answer_while_editing(sandbox: &Sandbox, edit: &'static str) -> PathBuf {
let path = settings_file(sandbox);
fs::write(&path, r#"{"model": "opus"}"#).unwrap();
let mut reader = EditsDuringPrompt {
answer: Cursor::new(b"y\n".to_vec()),
path: path.clone(),
edit: Some(edit),
};
configure_claude_code(
&sandbox.roots,
&sandbox.pulse_root(),
&mut reader,
true,
None,
);
path
}
#[test]
fn a_change_made_during_the_prompt_is_kept_and_this_runs_changes_reapplied() {
let sandbox = Sandbox::new();
let path = answer_while_editing(&sandbox, r#"{"theme": "dark"}"#);
let settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(settings["theme"], "dark", "{settings}");
assert!(
settings.get("model").is_none(),
"the stale read must not win: {settings}"
);
assert!(settings["hooks"]["SessionStart"].is_array(), "{settings}");
assert_eq!(settings["autoMemoryEnabled"], serde_json::json!(false));
assert_eq!(
fs::read_to_string(path.with_extension("json.bak")).unwrap(),
r#"{"theme": "dark"}"#
);
}
#[test]
fn a_file_broken_during_the_prompt_is_never_overwritten() {
let sandbox = Sandbox::new();
let path = answer_while_editing(&sandbox, "{broken");
assert_eq!(fs::read_to_string(&path).unwrap(), "{broken");
}
#[test]
fn a_symlinked_settings_file_is_never_replaced() {
let sandbox = Sandbox::new();
let dotfiles = sandbox.dir.path().join("dotfiles-settings.json");
fs::write(&dotfiles, r#"{"model": "opus"}"#).unwrap();
let path = settings_file(&sandbox);
std::os::unix::fs::symlink(&dotfiles, &path).unwrap();
let reader = ask_auto_memory(&sandbox, "y\n", true);
assert!(fs::symlink_metadata(&path)
.unwrap()
.file_type()
.is_symlink());
assert_eq!(
fs::read_to_string(&dotfiles).unwrap(),
r#"{"model": "opus"}"#
);
assert_eq!(
reader.position(),
0,
"nothing asked: nothing could be written"
);
}
#[test]
fn a_build_directory_binary_writes_no_rules_and_no_settings() {
let sandbox = configured_sandbox();
let roots = paths::ConfigRoots::sandboxed_in_build_dir(sandbox.dir.path())
.unwrap()
.with_interactive(true);
assert_eq!(
claude_settings::claude_code_target(&roots, &sandbox.pulse_root()).err(),
Some(claude_settings::Skip::BuildDir)
);
let path = settings_file(&sandbox);
fs::write(&path, r#"{"model": "opus"}"#).unwrap();
init_with(&sandbox, &roots, "y\n");
assert!(!sandbox_rules(&sandbox).exists());
assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"model": "opus"}"#);
assert!(!path.with_extension("json.bak").exists());
}
fn configured_sandbox() -> Sandbox {
let sandbox = Sandbox::new();
let memory = sandbox.pulse_root().join("memory");
fs::create_dir_all(&memory).unwrap();
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&memory, fs::Permissions::from_mode(0o700)).unwrap();
}
fs::write(
memory.join(".recall-echo.toml"),
"[llm]\nprovider = \"ollama\"\n",
)
.unwrap();
sandbox
}
fn init_with(sandbox: &Sandbox, roots: &paths::ConfigRoots, input: &str) {
let mut reader = Cursor::new(input.as_bytes().to_vec());
run_with(&sandbox.pulse_root(), &mut reader, roots).unwrap();
}
fn auto_memory_key(sandbox: &Sandbox) -> Option<serde_json::Value> {
let path = sandbox.roots.claude_dir().unwrap().join("settings.json");
let settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
settings.get("autoMemoryEnabled").cloned()
}
#[test]
fn init_takes_interactivity_from_its_roots() {
let quiet = configured_sandbox();
init_with(&quiet, &quiet.roots, "y\n");
assert_eq!(
auto_memory_key(&quiet),
None,
"a sandbox never asks by default"
);
let asked = configured_sandbox();
init_with(&asked, &asked.roots.clone().with_interactive(true), "y\n");
assert_eq!(auto_memory_key(&asked), Some(serde_json::json!(false)));
}
#[test]
fn init_takes_the_auto_memory_switch_from_its_roots() {
let sandbox = configured_sandbox();
let roots = sandbox
.roots
.clone()
.with_interactive(true)
.with_auto_memory_env(Some("1"));
init_with(&sandbox, &roots, "y\n");
assert_eq!(auto_memory_key(&sandbox), None, "already off: not asked");
}
#[test]
fn auto_memory_invalid_settings_refused() {
let sandbox = Sandbox::new();
let path = sandbox.roots.claude_dir().unwrap().join("settings.json");
fs::write(&path, "{not json").unwrap();
let reader = ask_auto_memory(&sandbox, "y\n", true);
assert_eq!(fs::read_to_string(&path).unwrap(), "{not json");
assert_eq!(reader.position(), 0, "nothing asked");
}
#[test]
fn auto_memory_skipped_for_pulse_root() {
let sandbox = Sandbox::new();
let pulse = sandbox.dir.path().join("elsewhere").join("echo");
fs::create_dir_all(&pulse).unwrap();
fs::write(pulse.join("pulse-null.toml"), "[pulse]\n").unwrap();
let path = seeded_settings(&sandbox);
let mut reader = Cursor::new(b"y\n".to_vec());
configure_claude_code(&sandbox.roots, &pulse, &mut reader, true, None);
let settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert!(settings.get("autoMemoryEnabled").is_none(), "{settings}");
assert!(settings.get("hooks").is_some(), "{settings}");
assert_eq!(reader.position(), 0, "nothing asked");
assert!(!sandbox_rules(&sandbox).exists());
}
#[test]
fn the_real_config_is_untouched_by_the_init_flow() {
let before = RealConfig::snapshot();
let sandbox = Sandbox::new();
sandbox.init("skip\n").unwrap();
assert!(sandbox.pulse_root().join("memory/MEMORY.md").exists());
assert!(sandbox
.roots
.pulse_root_file()
.is_some_and(std::path::Path::exists));
let hooks = fs::read_to_string(sandbox.roots.claude_dir().unwrap().join("settings.json"))
.expect("hooks landed in the sandbox");
for command in ["archive-session", "checkpoint", "consume"] {
assert!(
hooks.contains(&format!("{} {command}", sandbox.roots.recall_bin())),
"{command} missing from the sandboxed settings.json: {hooks}"
);
}
assert!(
sandbox_rules(&sandbox).exists(),
"the rules file landed in the sandbox"
);
before.assert_unchanged();
}
struct RealConfig {
entries: Vec<Digest>,
hook_commands: Vec<String>,
mcp_servers: Vec<String>,
}
impl RealConfig {
fn snapshot() -> Self {
Self {
entries: vec![
Digest::take(real_settings_file(), Strictness::Exact),
Digest::take(real_rules_file(), Strictness::Exact),
Digest::take(real_claude_json(), Strictness::WhenUntouched),
Digest::take(real_pointer_file("pulse-root"), Strictness::Exact),
Digest::take(real_pointer_file("entity-root"), Strictness::Exact),
],
hook_commands: recall_hook_commands(&real_settings_file()),
mcp_servers: mcp_server_names(&real_claude_json()),
}
}
fn assert_unchanged(&self) {
for entry in &self.entries {
entry.assert_unchanged();
}
assert_eq!(
recall_hook_commands(&real_settings_file()),
self.hook_commands,
"a recall-echo hook was added to or removed from the real settings.json"
);
assert_eq!(
mcp_server_names(&real_claude_json()),
self.mcp_servers,
"an MCP server was added to or removed from the real ~/.claude.json"
);
}
}
fn real_settings_file() -> PathBuf {
real_home().join(".claude").join("settings.json")
}
fn real_claude_json() -> PathBuf {
real_home().join(".claude.json")
}
fn real_rules_file() -> PathBuf {
real_home()
.join(".claude")
.join("rules")
.join("recall-echo.md")
}
fn real_pointer_file(name: &str) -> PathBuf {
let base = match std::env::var_os("XDG_CONFIG_HOME") {
Some(dir) if !dir.is_empty() => PathBuf::from(dir),
_ => real_home().join(".config"),
};
base.join("recall-echo").join(name)
}
fn real_home() -> PathBuf {
dirs::home_dir().expect("a home directory")
}
fn recall_hook_commands(settings: &Path) -> Vec<String> {
let Some(value) = read_json(settings) else {
return Vec::new();
};
let mut found: Vec<String> = Vec::new();
collect_hook_commands(&value, &mut found);
found.retain(|command| command.contains("recall-echo"));
found.sort();
found
}
fn collect_hook_commands(value: &serde_json::Value, out: &mut Vec<String>) {
match value {
serde_json::Value::Object(map) => {
if let Some(serde_json::Value::String(command)) = map.get("command") {
out.push(command.clone());
}
for nested in map.values() {
collect_hook_commands(nested, out);
}
}
serde_json::Value::Array(items) => {
for nested in items {
collect_hook_commands(nested, out);
}
}
_ => {}
}
}
fn mcp_server_names(claude_json: &Path) -> Vec<String> {
let Some(value) = read_json(claude_json) else {
return Vec::new();
};
let mut names: Vec<String> = value
.get("mcpServers")
.and_then(serde_json::Value::as_object)
.map(|servers| servers.keys().cloned().collect())
.unwrap_or_default();
names.sort();
names
}
fn read_json(path: &Path) -> Option<serde_json::Value> {
serde_json::from_str(&fs::read_to_string(path).ok()?).ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Strictness {
Exact,
WhenUntouched,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Digest {
path: PathBuf,
exists: bool,
len: u64,
sha256: String,
mtime: Option<SystemTime>,
strictness: Strictness,
}
impl Digest {
fn take(path: PathBuf, strictness: Strictness) -> Self {
use sha2::{Digest as _, Sha256};
let bytes = fs::read(&path).ok();
let sha256 = bytes
.as_ref()
.map_or_else(String::new, |bytes| format!("{:x}", Sha256::digest(bytes)));
Self {
exists: bytes.is_some(),
len: bytes.map_or(0, |bytes| bytes.len() as u64),
sha256,
mtime: fs::metadata(&path).ok().and_then(|m| m.modified().ok()),
path,
strictness,
}
}
fn assert_unchanged(&self) {
let now = Digest::take(self.path.clone(), self.strictness);
let path = self.path.display();
assert_eq!(
now.exists, self.exists,
"{path} came into being or vanished"
);
let compare_bytes = match self.strictness {
Strictness::Exact => {
assert_eq!(now.mtime, self.mtime, "{path} was touched");
true
}
Strictness::WhenUntouched => now.mtime == self.mtime,
};
if compare_bytes {
assert_eq!(now.len, self.len, "{path} changed length");
assert_eq!(now.sha256, self.sha256, "{path} was rewritten");
}
}
}
#[test]
fn archive_template_has_header() {
let sandbox = Sandbox::new();
sandbox.init("skip\n").unwrap();
let content = fs::read_to_string(sandbox.pulse_root().join("memory/ARCHIVE.md")).unwrap();
assert!(content.contains("# Conversation Archive"));
assert!(content.contains("| # | Date"));
}
}
#[cfg(test)]
mod suite_fence {
use std::path::{Path, PathBuf};
const FORBIDDEN: [&str; 3] = [
"run_with_reader(", "init::run(", "ConfigRoots::from_env()", ];
fn rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries {
let path = entry.expect("dir entry").path();
if path.is_dir() {
rs_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
fn test_region(source: &str, whole_file: bool) -> &str {
if whole_file {
return source;
}
source
.find("#[cfg(test)]")
.map_or("", |start| &source[start..])
}
#[test]
fn no_test_reaches_the_real_configuration() {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let mut files = Vec::new();
rs_files(&manifest.join("src"), &mut files);
let src_count = files.len();
rs_files(&manifest.join("tests"), &mut files);
assert!(
src_count > 0 && files.len() > src_count,
"scan walked nothing — CARGO_MANIFEST_DIR wrong?"
);
let offenders: Vec<String> = files
.iter()
.flat_map(|path| {
let source = std::fs::read_to_string(path).expect("read");
let whole_file = path.starts_with(manifest.join("tests"));
let region = test_region(&source, whole_file).to_string();
let path = path.clone();
region
.lines()
.filter(|line| !line.contains("sanctioned:"))
.filter(|line| FORBIDDEN.iter().any(|needle| line.contains(needle)))
.map(|line| format!("{}: {}", path.display(), line.trim()))
.collect::<Vec<_>>()
})
.collect();
assert!(
offenders.is_empty(),
"tests must take `init::run_with` with sandboxed ConfigRoots:\n{}",
offenders.join("\n")
);
}
}