Skip to content

Commit 342feb8

Browse files
committed
fix: unify hook predicates, atomic CLAUDE.md write, error propagation
H1: extract session_start_commands iterator — hook_command_exists and check_session_start_hook now share one code path (no divergence). Status check uses ends_with(" session-start") to survive binary reinstall; init guard uses exact match to prevent duplicate entries. H2: wire_claude_md uses tmp→rename (atomic write) instead of bare fs::write — consistent with all other write paths. M1: save_index removes .tmp file on rename failure to avoid orphans. M2: cmd_index uses anyhow::bail! instead of process::exit(1) so the error propagates through main's Result chain. M3: tests for resolve_cwd (project_override path) and HookStdin JSON parsing (cwd present / cwd absent). M4: find_memory_md logs canonicalize failures to stderr instead of silently returning None.
1 parent 002f4ce commit 342feb8

1 file changed

Lines changed: 67 additions & 45 deletions

File tree

src/main.rs

Lines changed: 67 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,9 @@ fn wire_claude_md(path: &Path) -> Result<bool> {
172172
format!("{existing}\n\n{CLAUDE_MD_BLOCK}")
173173
};
174174

175-
std::fs::write(path, new_content).with_context(|| format!("write {}", path.display()))?;
175+
let tmp = path.with_extension("md.tmp");
176+
std::fs::write(&tmp, &new_content).with_context(|| format!("write {}", tmp.display()))?;
177+
std::fs::rename(&tmp, path).with_context(|| format!("rename to {}", path.display()))?;
176178
Ok(true)
177179
}
178180

@@ -328,26 +330,21 @@ fn cmd_index() -> Result<()> {
328330

329331
save_index(&existing)?;
330332

333+
println!(
334+
"Indexed: {} new, {} updated, {} unchanged, {} pruned{} ({} total)",
335+
new_count,
336+
updated_count,
337+
unchanged_count,
338+
pruned,
339+
if error_count > 0 {
340+
format!(", {} errors", error_count)
341+
} else {
342+
String::new()
343+
},
344+
existing.len()
345+
);
331346
if error_count > 0 {
332-
println!(
333-
"Indexed: {} new, {} updated, {} unchanged, {} pruned, {} errors ({} total)",
334-
new_count,
335-
updated_count,
336-
unchanged_count,
337-
pruned,
338-
error_count,
339-
existing.len()
340-
);
341-
std::process::exit(1);
342-
} else {
343-
println!(
344-
"Indexed: {} new, {} updated, {} unchanged, {} pruned ({} total)",
345-
new_count,
346-
updated_count,
347-
unchanged_count,
348-
pruned,
349-
existing.len()
350-
);
347+
anyhow::bail!("{error_count} file(s) could not be read");
351348
}
352349
Ok(())
353350
}
@@ -425,7 +422,10 @@ fn save_index(entries: &[IndexEntry]) -> Result<()> {
425422
let tmp = path.with_extension("json.tmp");
426423
std::fs::write(&tmp, serde_json::to_string(entries)?)
427424
.with_context(|| format!("write {}", tmp.display()))?;
428-
std::fs::rename(&tmp, &path).with_context(|| format!("rename to {}", path.display()))?;
425+
if let Err(e) = std::fs::rename(&tmp, &path) {
426+
let _ = std::fs::remove_file(&tmp);
427+
return Err(e).with_context(|| format!("rename to {}", path.display()));
428+
}
429429
Ok(())
430430
}
431431

@@ -469,7 +469,13 @@ fn find_memory_md(cwd: &Path) -> Option<(String, PathBuf)> {
469469
}
470470
// Strategy 2: ~/.claude/projects/<encoded>/memory/MEMORY.md
471471
let projects = dirs::home_dir()?.join(".claude").join("projects");
472-
let canonical = std::fs::canonicalize(cwd).ok()?;
472+
let canonical = match std::fs::canonicalize(cwd) {
473+
Ok(p) => p,
474+
Err(e) => {
475+
eprintln!("mem: cannot canonicalize {}: {e}", cwd.display());
476+
return None;
477+
}
478+
};
473479
let encoded = "-".to_string()
474480
+ &canonical
475481
.to_string_lossy()
@@ -512,17 +518,22 @@ fn file_mtime(path: &Path) -> i64 {
512518
}
513519

514520
fn hook_command_exists(entry: &serde_json::Value, cmd: &str) -> bool {
515-
entry.as_array().is_some_and(|arr| {
516-
arr.iter().any(|item| {
521+
session_start_commands(entry).any(|c| c == cmd)
522+
}
523+
524+
/// Iterator over every `command` string inside a SessionStart hook array.
525+
fn session_start_commands<'a>(entry: &'a serde_json::Value) -> impl Iterator<Item = &'a str> + 'a {
526+
entry
527+
.as_array()
528+
.into_iter()
529+
.flat_map(|arr| arr.iter())
530+
.flat_map(|item| {
517531
item.get("hooks")
518532
.and_then(|h| h.as_array())
519-
.is_some_and(|hooks| {
520-
hooks
521-
.iter()
522-
.any(|h| h.get("command").and_then(|c| c.as_str()) == Some(cmd))
523-
})
533+
.into_iter()
534+
.flat_map(|hooks| hooks.iter())
524535
})
525-
})
536+
.filter_map(|h| h.get("command").and_then(|c| c.as_str()))
526537
}
527538

528539
fn atomic_write_json(path: &Path, value: &serde_json::Value) -> Result<()> {
@@ -540,23 +551,13 @@ fn check_session_start_hook(settings_path: &Path) -> &'static str {
540551
let Ok(val) = serde_json::from_str::<serde_json::Value>(&raw) else {
541552
return "malformed settings.json";
542553
};
543-
let has_hook = val
554+
let entry = val
544555
.get("hooks")
545556
.and_then(|h| h.get("SessionStart"))
546-
.and_then(|s| s.as_array())
547-
.is_some_and(|arr| {
548-
arr.iter().any(|item| {
549-
item.get("hooks")
550-
.and_then(|h| h.as_array())
551-
.is_some_and(|hooks| {
552-
hooks.iter().any(|h| {
553-
h.get("command")
554-
.and_then(|c| c.as_str())
555-
.is_some_and(|c| c.ends_with(" session-start"))
556-
})
557-
})
558-
})
559-
});
557+
.cloned()
558+
.unwrap_or(serde_json::Value::Array(vec![]));
559+
// Accept any command ending with " session-start" to handle path changes after reinstall.
560+
let has_hook = session_start_commands(&entry).any(|c| c.ends_with(" session-start"));
560561
if has_hook {
561562
"installed"
562563
} else {
@@ -690,6 +691,27 @@ mod tests {
690691
assert_eq!(loaded[0].content, "- Used JWT for auth");
691692
}
692693

694+
#[test]
695+
fn resolve_cwd_uses_project_override() {
696+
let tmp = tempfile::tempdir().unwrap();
697+
let result = resolve_cwd(Some(tmp.path().to_path_buf())).unwrap();
698+
assert_eq!(result, tmp.path());
699+
}
700+
701+
#[test]
702+
fn hook_stdin_parses_cwd_field() {
703+
let json = r#"{"cwd":"/tmp/myproject","sessionId":"abc"}"#;
704+
let parsed: HookStdin = serde_json::from_str(json).unwrap();
705+
assert_eq!(parsed.cwd.as_deref(), Some("/tmp/myproject"));
706+
}
707+
708+
#[test]
709+
fn hook_stdin_missing_cwd_is_none() {
710+
let json = r#"{"sessionId":"abc"}"#;
711+
let parsed: HookStdin = serde_json::from_str(json).unwrap();
712+
assert!(parsed.cwd.is_none());
713+
}
714+
693715
#[test]
694716
fn search_matches_lines_case_insensitive() {
695717
let entries = vec![IndexEntry {

0 commit comments

Comments
 (0)