Skip to main content

rch_common/
hooks.rs

1//! Daemon hook verification and installation.
2//!
3//! This module provides self-healing hook management for AI coding agents.
4//! The daemon can call [`verify_and_install_claude_code_hook`] at startup to
5//! ensure the RCH hook is installed in Claude Code's settings.
6
7use anyhow::{Context, Result};
8use serde_json::Value;
9use std::fs;
10use std::io::Write;
11use std::path::PathBuf;
12use tracing::{debug, info, warn};
13use uuid::Uuid;
14
15/// Result of hook verification/installation.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum HookResult {
18    /// Hook was already installed and configured correctly.
19    AlreadyInstalled,
20    /// Hook was successfully installed during this call.
21    Installed,
22    /// Hook installation was skipped (with reason).
23    Skipped(String),
24    /// Agent/hook is not applicable (e.g., Claude Code not installed).
25    NotApplicable,
26}
27
28impl std::fmt::Display for HookResult {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            HookResult::AlreadyInstalled => write!(f, "already installed"),
32            HookResult::Installed => write!(f, "installed"),
33            HookResult::Skipped(reason) => write!(f, "skipped: {}", reason),
34            HookResult::NotApplicable => write!(f, "not applicable"),
35        }
36    }
37}
38
39/// Gets the path to Claude Code settings.json.
40fn claude_code_settings_path() -> Option<PathBuf> {
41    dirs::home_dir().map(|h| h.join(".claude").join("settings.json"))
42}
43
44/// Return true when the given command-line invokes the `rch` binary.
45///
46/// Matches against the basename of the first whitespace-separated token,
47/// not a naive `contains("rch")`. The substring test was prone to false
48/// positives on any path or arg containing "rch" in its name — `search`,
49/// `archive`, `rearchive`, `rch-wkr`, `rchd`, `myrchwrapper` — which
50/// would have made the daemon's self-healing check falsely report that
51/// the user's Claude Code settings already contained the RCH hook.
52fn is_rch_hook_command(cmd: &str) -> bool {
53    let first_token = cmd.split_whitespace().next().unwrap_or(cmd);
54    let basename = std::path::Path::new(first_token)
55        .file_name()
56        .and_then(|s| s.to_str())
57        .unwrap_or(first_token);
58    basename == "rch"
59}
60
61/// Gets the path to the .claude directory.
62fn claude_code_dir() -> Option<PathBuf> {
63    dirs::home_dir().map(|h| h.join(".claude"))
64}
65
66/// Checks if Claude Code appears to be installed.
67///
68/// Returns true if the ~/.claude directory exists.
69pub fn is_claude_code_installed() -> bool {
70    claude_code_dir().map(|p| p.exists()).unwrap_or(false)
71}
72
73/// Extract the `rch` PreToolUse hook command from parsed Claude Code settings.
74///
75/// Pure (no filesystem) so the daemon's startup consistency check can run it
76/// against fixtures rather than the real user settings file. Returns the
77/// command string of the first PreToolUse hook whose first token is the `rch`
78/// binary — across the nested format
79/// (`{"matcher": "Bash", "hooks": [{"command": "rch"}]}`), the legacy flat
80/// format (`{"command": "rch"}`), and bare-string hooks. Returns `None` when
81/// no rch hook is present.
82#[must_use]
83pub fn rch_pretooluse_hook_command(settings: &Value) -> Option<String> {
84    let arr = settings.get("hooks")?.get("PreToolUse")?.as_array()?;
85    for hook in arr {
86        // New nested format.
87        if let Some(inner_hooks) = hook.get("hooks").and_then(|h| h.as_array()) {
88            for inner in inner_hooks {
89                if let Some(cmd) = inner.get("command").and_then(|c| c.as_str())
90                    && is_rch_hook_command(cmd)
91                {
92                    return Some(cmd.to_string());
93                }
94            }
95        }
96        // Legacy flat format.
97        if let Some(cmd) = hook.get("command").and_then(|c| c.as_str())
98            && is_rch_hook_command(cmd)
99        {
100            return Some(cmd.to_string());
101        }
102        // Bare-string hook.
103        if let Some(cmd) = hook.as_str()
104            && is_rch_hook_command(cmd)
105        {
106            return Some(cmd.to_string());
107        }
108    }
109    None
110}
111
112/// Read the installed `rch` PreToolUse hook command from the user's Claude Code
113/// `settings.json`, if present.
114///
115/// Returns `None` when the settings file is absent, unreadable, malformed, or
116/// contains no rch hook. Never mutates the file — this is a read-only probe for
117/// the daemon's startup consistency check.
118#[must_use]
119pub fn installed_rch_hook_command() -> Option<String> {
120    let settings_path = claude_code_settings_path()?;
121    let content = fs::read_to_string(&settings_path).ok()?;
122    let settings: Value = serde_json::from_str(&content).ok()?;
123    rch_pretooluse_hook_command(&settings)
124}
125
126/// Checks if the RCH hook is installed in Claude Code settings.
127fn check_claude_code_hook_installed() -> Result<bool> {
128    let settings_path = match claude_code_settings_path() {
129        Some(p) => p,
130        None => return Ok(false),
131    };
132
133    if !settings_path.exists() {
134        return Ok(false);
135    }
136
137    let content =
138        fs::read_to_string(&settings_path).context("Failed to read Claude Code settings")?;
139
140    let settings: Value =
141        serde_json::from_str(&content).context("Failed to parse Claude Code settings")?;
142
143    Ok(rch_pretooluse_hook_command(&settings).is_some())
144}
145
146/// Writes content to a file atomically using a temporary file.
147///
148/// Cleans up the temp file on any failure path — otherwise a write/sync/
149/// rename error (e.g. disk-full, EPERM, cross-device) would leave a
150/// `.UUID.tmp` orphan next to the real settings file that grows on every
151/// retry. We rely on `fs::rename` replacing the target atomically on
152/// success, so the temp file only exists transiently in the happy path.
153fn atomic_write(path: &PathBuf, content: &[u8]) -> Result<()> {
154    let parent = path.parent().context("Path has no parent directory")?;
155    let temp_path = parent.join(format!(".{}.tmp", Uuid::new_v4()));
156
157    // Guard removes the temp file if any `?` below bails out. Defused
158    // after a successful `fs::rename`, where the file has already been
159    // moved into place.
160    struct TempGuard<'a>(Option<&'a std::path::Path>);
161    impl Drop for TempGuard<'_> {
162        fn drop(&mut self) {
163            if let Some(p) = self.0 {
164                let _ = fs::remove_file(p);
165            }
166        }
167    }
168    let mut guard = TempGuard(Some(temp_path.as_path()));
169
170    {
171        let mut file = fs::File::create(&temp_path)
172            .with_context(|| format!("Failed to create temp file {:?}", temp_path))?;
173        file.write_all(content)
174            .with_context(|| format!("Failed to write to temp file {:?}", temp_path))?;
175        file.sync_all().context("Failed to sync temp file")?;
176    }
177
178    fs::rename(&temp_path, path)
179        .with_context(|| format!("Failed to rename {:?} to {:?}", temp_path, path))?;
180    // Rename succeeded — temp_path no longer exists, so skip cleanup.
181    guard.0 = None;
182
183    Ok(())
184}
185
186/// Creates a backup of a file if it exists.
187fn create_backup(path: &PathBuf) -> Result<PathBuf> {
188    let backup_name = format!(
189        "{}.bak.{}",
190        path.file_name().and_then(|n| n.to_str()).unwrap_or("file"),
191        chrono::Utc::now().format("%Y%m%d_%H%M%S")
192    );
193    let backup_path = path
194        .parent()
195        .map(|p| p.join(&backup_name))
196        .unwrap_or_else(|| PathBuf::from(&backup_name));
197
198    fs::copy(path, &backup_path)
199        .with_context(|| format!("Failed to create backup at {:?}", backup_path))?;
200
201    debug!("Created backup: {:?}", backup_path);
202    Ok(backup_path)
203}
204
205/// Verifies and installs the RCH hook in Claude Code settings.
206///
207/// This is the main entry point for daemon hook self-healing. It:
208/// 1. Checks if Claude Code is installed (~/.claude exists)
209/// 2. Checks if the hook is already installed
210/// 3. Installs the hook if needed
211///
212/// # Returns
213///
214/// - `HookResult::AlreadyInstalled` - Hook was already present
215/// - `HookResult::Installed` - Hook was just installed
216/// - `HookResult::NotApplicable` - Claude Code is not installed
217/// - `HookResult::Skipped(reason)` - Installation was skipped for a reason
218///
219/// # Errors
220///
221/// Returns an error if file operations fail (read/write/parse).
222pub fn verify_and_install_claude_code_hook() -> Result<HookResult> {
223    // Check if Claude Code is installed
224    let claude_dir = match claude_code_dir() {
225        Some(dir) => dir,
226        None => {
227            debug!("Could not determine home directory");
228            return Ok(HookResult::NotApplicable);
229        }
230    };
231
232    if !claude_dir.exists() {
233        debug!("Claude Code not installed (~/.claude does not exist)");
234        return Ok(HookResult::NotApplicable);
235    }
236
237    // Check if hook is already installed
238    match check_claude_code_hook_installed() {
239        Ok(true) => {
240            debug!("Claude Code hook already installed");
241            return Ok(HookResult::AlreadyInstalled);
242        }
243        Ok(false) => {
244            // Proceed with installation
245        }
246        Err(e) => {
247            // If we can't check, log warning but try to install anyway
248            warn!(
249                "Could not check hook status: {}, attempting installation",
250                e
251            );
252        }
253    }
254
255    // Install the hook
256    let settings_path = claude_code_settings_path()
257        .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
258
259    // Ensure .claude directory exists
260    if let Some(parent) = settings_path.parent() {
261        fs::create_dir_all(parent)?;
262    }
263
264    // Read existing settings or create new
265    let mut settings: Value = if settings_path.exists() {
266        let content = fs::read_to_string(&settings_path)?;
267        match serde_json::from_str(&content) {
268            Ok(v) => v,
269            Err(e) => {
270                warn!(
271                    "Existing settings.json is malformed: {}, creating fresh settings",
272                    e
273                );
274                serde_json::json!({})
275            }
276        }
277    } else {
278        serde_json::json!({})
279    };
280
281    // Create backup if file exists
282    if settings_path.exists()
283        && let Err(e) = create_backup(&settings_path)
284    {
285        warn!("Could not create backup: {}", e);
286        // Continue anyway - the backup is nice to have but not critical
287    }
288
289    // Add the hook using the new Claude Code hooks format
290    let hook_entry = serde_json::json!({
291        "matcher": "Bash",
292        "hooks": [
293            {
294                "type": "command",
295                "command": "rch"
296            }
297        ]
298    });
299
300    let hooks = settings
301        .as_object_mut()
302        .ok_or_else(|| anyhow::anyhow!("Settings is not an object"))?
303        .entry("hooks")
304        .or_insert_with(|| serde_json::json!({}));
305
306    let hooks_obj = hooks
307        .as_object_mut()
308        .ok_or_else(|| anyhow::anyhow!("Hooks is not an object"))?;
309
310    let pre_tool_use = hooks_obj
311        .entry("PreToolUse")
312        .or_insert_with(|| serde_json::json!([]));
313
314    if !pre_tool_use.is_array() {
315        *pre_tool_use = serde_json::json!([]);
316    }
317    pre_tool_use
318        .as_array_mut()
319        .ok_or_else(|| anyhow::anyhow!("PreToolUse should be an array after initialization"))?
320        .push(hook_entry);
321
322    // Write settings atomically
323    let content = serde_json::to_string_pretty(&settings)?;
324    atomic_write(&settings_path, content.as_bytes())?;
325
326    info!("Installed RCH hook in Claude Code settings");
327    Ok(HookResult::Installed)
328}
329
330/// Internal helper to check if RCH hook is present in a settings Value.
331/// Used for testing and by the main verification function.
332#[allow(dead_code)]
333fn settings_has_rch_hook(settings: &Value) -> bool {
334    if let Some(hooks) = settings.get("hooks")
335        && let Some(pre_tool_use) = hooks.get("PreToolUse")
336        && let Some(arr) = pre_tool_use.as_array()
337    {
338        for hook in arr {
339            // Check new format: {"matcher": "Bash", "hooks": [{"type": "command", "command": "rch"}]}
340            if let Some(inner_hooks) = hook.get("hooks").and_then(|h| h.as_array()) {
341                for inner in inner_hooks {
342                    if let Some(cmd) = inner.get("command").and_then(|c| c.as_str())
343                        && is_rch_hook_command(cmd)
344                    {
345                        return true;
346                    }
347                }
348            }
349            // Check old format: {"command": "rch"} (for backwards compatibility)
350            if let Some(cmd) = hook.get("command").and_then(|c| c.as_str())
351                && is_rch_hook_command(cmd)
352            {
353                return true;
354            }
355            if let Some(cmd) = hook.as_str()
356                && is_rch_hook_command(cmd)
357            {
358                return true;
359            }
360        }
361    }
362    false
363}
364
365/// Internal helper to add RCH hook to settings Value.
366/// Returns the modified settings.
367#[allow(dead_code)]
368fn add_rch_hook_to_settings(mut settings: Value) -> Result<Value> {
369    let hook_entry = serde_json::json!({
370        "matcher": "Bash",
371        "hooks": [
372            {
373                "type": "command",
374                "command": "rch"
375            }
376        ]
377    });
378
379    let hooks = settings
380        .as_object_mut()
381        .ok_or_else(|| anyhow::anyhow!("Settings is not an object"))?
382        .entry("hooks")
383        .or_insert_with(|| serde_json::json!({}));
384
385    let hooks_obj = hooks
386        .as_object_mut()
387        .ok_or_else(|| anyhow::anyhow!("Hooks is not an object"))?;
388
389    let pre_tool_use = hooks_obj
390        .entry("PreToolUse")
391        .or_insert_with(|| serde_json::json!([]));
392
393    if !pre_tool_use.is_array() {
394        *pre_tool_use = serde_json::json!([]);
395    }
396    pre_tool_use
397        .as_array_mut()
398        .ok_or_else(|| anyhow::anyhow!("PreToolUse should be an array after initialization"))?
399        .push(hook_entry);
400
401    Ok(settings)
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use serde_json::json;
408    use tempfile::TempDir;
409
410    // =========================================================================
411    // rch_pretooluse_hook_command — pure hook extractor for startup checks
412    // =========================================================================
413
414    #[test]
415    fn pretooluse_hook_command_reads_nested_format() {
416        let settings = json!({
417            "hooks": {
418                "PreToolUse": [
419                    { "matcher": "Bash", "hooks": [{ "type": "command", "command": "/usr/local/bin/rch" }] }
420                ]
421            }
422        });
423        assert_eq!(
424            rch_pretooluse_hook_command(&settings).as_deref(),
425            Some("/usr/local/bin/rch")
426        );
427    }
428
429    #[test]
430    fn pretooluse_hook_command_reads_legacy_flat_and_string_forms() {
431        let flat = json!({ "hooks": { "PreToolUse": [{ "command": "rch" }] } });
432        assert_eq!(rch_pretooluse_hook_command(&flat).as_deref(), Some("rch"));
433        let string_form = json!({ "hooks": { "PreToolUse": ["rch --flag"] } });
434        assert_eq!(
435            rch_pretooluse_hook_command(&string_form).as_deref(),
436            Some("rch --flag")
437        );
438    }
439
440    #[test]
441    fn pretooluse_hook_command_none_when_absent_or_foreign() {
442        assert_eq!(rch_pretooluse_hook_command(&json!({})), None);
443        assert_eq!(
444            rch_pretooluse_hook_command(&json!({ "hooks": { "PreToolUse": [] } })),
445            None
446        );
447        // A non-rch hook (false-positive guard: "archive" contains "rch").
448        let foreign = json!({
449            "hooks": { "PreToolUse": [{ "hooks": [{ "command": "archive --now" }] }] }
450        });
451        assert_eq!(rch_pretooluse_hook_command(&foreign), None);
452    }
453
454    // =========================================================================
455    // is_rch_hook_command — guard against the `contains("rch")` bug
456    // =========================================================================
457
458    #[test]
459    fn is_rch_hook_command_matches_rch() {
460        assert!(is_rch_hook_command("rch"));
461        assert!(is_rch_hook_command("/usr/local/bin/rch"));
462        assert!(is_rch_hook_command("rch --some-flag"));
463        assert!(is_rch_hook_command("/home/user/.local/bin/rch arg1 arg2"));
464    }
465
466    #[test]
467    fn is_rch_hook_command_rejects_substring_false_positives() {
468        // Regression: the previous `contains("rch")` misidentified all
469        // of these as the RCH hook, so `verify_and_install_claude_code_hook`
470        // would skip installation even when the user's hook was some
471        // unrelated tool whose name happened to contain "rch".
472        assert!(!is_rch_hook_command("search"));
473        assert!(!is_rch_hook_command("/usr/bin/search"));
474        assert!(!is_rch_hook_command("archive"));
475        assert!(!is_rch_hook_command("rearchive"));
476        assert!(!is_rch_hook_command("rch-wkr"));
477        assert!(!is_rch_hook_command("rchd"));
478        assert!(!is_rch_hook_command("myrchwrapper"));
479    }
480
481    // =========================================================================
482    // HookResult Display Tests
483    // =========================================================================
484
485    #[test]
486    fn test_hook_result_display() {
487        assert_eq!(
488            HookResult::AlreadyInstalled.to_string(),
489            "already installed"
490        );
491        assert_eq!(HookResult::Installed.to_string(), "installed");
492        assert_eq!(
493            HookResult::Skipped("reason".to_string()).to_string(),
494            "skipped: reason"
495        );
496        assert_eq!(HookResult::NotApplicable.to_string(), "not applicable");
497    }
498
499    #[test]
500    fn test_hook_result_equality() {
501        assert_eq!(HookResult::AlreadyInstalled, HookResult::AlreadyInstalled);
502        assert_eq!(HookResult::Installed, HookResult::Installed);
503        assert_eq!(HookResult::NotApplicable, HookResult::NotApplicable);
504        assert_eq!(
505            HookResult::Skipped("test".to_string()),
506            HookResult::Skipped("test".to_string())
507        );
508        assert_ne!(
509            HookResult::Skipped("a".to_string()),
510            HookResult::Skipped("b".to_string())
511        );
512    }
513
514    #[allow(unsafe_code)]
515    mod install_tests {
516        use super::*;
517        use crate::config::env_test_lock;
518
519        fn env_guard() -> std::sync::MutexGuard<'static, ()> {
520            env_test_lock()
521        }
522
523        fn set_env(key: &str, value: &str) {
524            // SAFETY: Tests are serialized with env_guard().
525            unsafe { std::env::set_var(key, value) };
526        }
527
528        fn remove_env(key: &str) {
529            // SAFETY: Tests are serialized with env_guard().
530            unsafe { std::env::remove_var(key) };
531        }
532
533        struct EnvVarGuard {
534            key: &'static str,
535            old: Option<String>,
536        }
537
538        impl EnvVarGuard {
539            fn set(key: &'static str, value: &str) -> Self {
540                let old = std::env::var(key).ok();
541                set_env(key, value);
542                Self { key, old }
543            }
544        }
545
546        impl Drop for EnvVarGuard {
547            fn drop(&mut self) {
548                if let Some(old) = &self.old {
549                    set_env(self.key, old);
550                } else {
551                    remove_env(self.key);
552                }
553            }
554        }
555
556        #[test]
557        fn test_verify_and_install_not_applicable_does_not_create_claude_dir() {
558            let _guard = env_guard();
559
560            let tmp = TempDir::new().unwrap();
561            let home = tmp.path().to_string_lossy().to_string();
562            let _home = EnvVarGuard::set("HOME", &home);
563
564            let claude_dir = tmp.path().join(".claude");
565            assert!(!claude_dir.exists());
566
567            let result = verify_and_install_claude_code_hook().unwrap();
568            assert_eq!(result, HookResult::NotApplicable);
569
570            // Critical: the function must NOT create ~/.claude when absent.
571            assert!(!claude_dir.exists(), "Should not create ~/.claude");
572        }
573
574        #[test]
575        fn test_verify_and_install_installs_hook_when_claude_dir_exists() {
576            let _guard = env_guard();
577
578            let tmp = TempDir::new().unwrap();
579            let home = tmp.path().to_string_lossy().to_string();
580            let _home = EnvVarGuard::set("HOME", &home);
581
582            let claude_dir = tmp.path().join(".claude");
583            fs::create_dir_all(&claude_dir).unwrap();
584            let settings_path = claude_dir.join("settings.json");
585            assert!(!settings_path.exists());
586
587            let result = verify_and_install_claude_code_hook().unwrap();
588            assert_eq!(result, HookResult::Installed);
589
590            let settings_str = fs::read_to_string(&settings_path).unwrap();
591            let settings: Value = serde_json::from_str(&settings_str).unwrap();
592            assert!(
593                settings_has_rch_hook(&settings),
594                "Installed settings should contain rch hook"
595            );
596        }
597
598        #[test]
599        fn test_verify_and_install_already_installed_does_not_modify_file() {
600            let _guard = env_guard();
601
602            let tmp = TempDir::new().unwrap();
603            let home = tmp.path().to_string_lossy().to_string();
604            let _home = EnvVarGuard::set("HOME", &home);
605
606            let claude_dir = tmp.path().join(".claude");
607            fs::create_dir_all(&claude_dir).unwrap();
608            let settings_path = claude_dir.join("settings.json");
609
610            let settings = json!({
611                "hooks": {
612                    "PreToolUse": [
613                        {
614                            "matcher": "Bash",
615                            "hooks": [
616                                { "type": "command", "command": "rch" }
617                            ]
618                        }
619                    ]
620                }
621            });
622            fs::write(
623                &settings_path,
624                serde_json::to_string_pretty(&settings).unwrap(),
625            )
626            .unwrap();
627            let before = fs::read_to_string(&settings_path).unwrap();
628
629            let result = verify_and_install_claude_code_hook().unwrap();
630            assert_eq!(result, HookResult::AlreadyInstalled);
631
632            let after = fs::read_to_string(&settings_path).unwrap();
633            assert_eq!(
634                after, before,
635                "AlreadyInstalled should not rewrite settings.json"
636            );
637        }
638
639        #[test]
640        fn test_verify_and_install_coerces_pre_tool_use_to_array() {
641            let _guard = env_guard();
642
643            let tmp = TempDir::new().unwrap();
644            let home = tmp.path().to_string_lossy().to_string();
645            let _home = EnvVarGuard::set("HOME", &home);
646
647            let claude_dir = tmp.path().join(".claude");
648            fs::create_dir_all(&claude_dir).unwrap();
649            let settings_path = claude_dir.join("settings.json");
650
651            let settings = json!({
652                "hooks": {
653                    "PreToolUse": { "not": "an array" }
654                }
655            });
656            fs::write(
657                &settings_path,
658                serde_json::to_string_pretty(&settings).unwrap(),
659            )
660            .unwrap();
661
662            let result = verify_and_install_claude_code_hook().unwrap();
663            assert_eq!(result, HookResult::Installed);
664
665            let settings_str = fs::read_to_string(&settings_path).unwrap();
666            let settings: Value = serde_json::from_str(&settings_str).unwrap();
667            assert!(
668                settings
669                    .get("hooks")
670                    .and_then(|h| h.get("PreToolUse"))
671                    .and_then(|v| v.as_array())
672                    .is_some(),
673                "PreToolUse should be coerced to an array"
674            );
675            assert!(
676                settings_has_rch_hook(&settings),
677                "Installed settings should contain rch hook"
678            );
679        }
680    }
681
682    // =========================================================================
683    // Atomic Write Tests
684    // =========================================================================
685
686    #[test]
687    fn test_atomic_write() {
688        let temp_dir = TempDir::new().unwrap();
689        let file_path = temp_dir.path().join("test.json");
690
691        atomic_write(&file_path, b"test content").unwrap();
692
693        let content = fs::read_to_string(&file_path).unwrap();
694        assert_eq!(content, "test content");
695    }
696
697    #[test]
698    fn test_atomic_write_overwrites_existing() {
699        let temp_dir = TempDir::new().unwrap();
700        let file_path = temp_dir.path().join("existing.json");
701
702        fs::write(&file_path, "old content").unwrap();
703        atomic_write(&file_path, b"new content").unwrap();
704
705        let content = fs::read_to_string(&file_path).unwrap();
706        assert_eq!(content, "new content");
707    }
708
709    #[test]
710    fn test_atomic_write_nested_directory() {
711        let temp_dir = TempDir::new().unwrap();
712        let nested_dir = temp_dir.path().join("nested").join("deep");
713        fs::create_dir_all(&nested_dir).unwrap();
714        let file_path = nested_dir.join("test.json");
715
716        atomic_write(&file_path, b"nested content").unwrap();
717
718        let content = fs::read_to_string(&file_path).unwrap();
719        assert_eq!(content, "nested content");
720    }
721
722    // =========================================================================
723    // Backup Tests
724    // =========================================================================
725
726    #[test]
727    fn test_create_backup() {
728        let temp_dir = TempDir::new().unwrap();
729        let file_path = temp_dir.path().join("original.json");
730
731        fs::write(&file_path, "original content").unwrap();
732
733        let backup_path = create_backup(&file_path).unwrap();
734
735        assert!(backup_path.exists());
736        let backup_content = fs::read_to_string(&backup_path).unwrap();
737        assert_eq!(backup_content, "original content");
738    }
739
740    #[test]
741    fn test_create_backup_preserves_original() {
742        let temp_dir = TempDir::new().unwrap();
743        let file_path = temp_dir.path().join("original.json");
744
745        fs::write(&file_path, "original content").unwrap();
746        let _ = create_backup(&file_path).unwrap();
747
748        // Original should still exist
749        assert!(file_path.exists());
750        let content = fs::read_to_string(&file_path).unwrap();
751        assert_eq!(content, "original content");
752    }
753
754    #[test]
755    fn test_create_backup_naming_format() {
756        let temp_dir = TempDir::new().unwrap();
757        let file_path = temp_dir.path().join("settings.json");
758
759        fs::write(&file_path, "content").unwrap();
760        let backup_path = create_backup(&file_path).unwrap();
761
762        let backup_name = backup_path.file_name().unwrap().to_str().unwrap();
763        assert!(backup_name.starts_with("settings.json.bak."));
764    }
765
766    // =========================================================================
767    // Settings Hook Detection Tests
768    // =========================================================================
769
770    #[test]
771    fn test_settings_has_rch_hook_with_object_hook() {
772        // New format with nested hooks array
773        let settings = json!({
774            "hooks": {
775                "PreToolUse": [
776                    {
777                        "matcher": "Bash",
778                        "hooks": [{"type": "command", "command": "rch"}]
779                    }
780                ]
781            }
782        });
783        assert!(settings_has_rch_hook(&settings));
784    }
785
786    #[test]
787    fn test_settings_has_rch_hook_with_old_format() {
788        // Old format for backwards compatibility
789        let settings = json!({
790            "hooks": {
791                "PreToolUse": [
792                    {"command": "rch", "description": "RCH hook"}
793                ]
794            }
795        });
796        assert!(settings_has_rch_hook(&settings));
797    }
798
799    #[test]
800    fn test_settings_has_rch_hook_with_string_hook() {
801        let settings = json!({
802            "hooks": {
803                "PreToolUse": ["rch"]
804            }
805        });
806        assert!(settings_has_rch_hook(&settings));
807    }
808
809    #[test]
810    fn test_settings_has_rch_hook_partial_match() {
811        // Test that a command containing "rch" is detected (new format)
812        let settings = json!({
813            "hooks": {
814                "PreToolUse": [
815                    {
816                        "matcher": "Bash",
817                        "hooks": [{"type": "command", "command": "/usr/local/bin/rch"}]
818                    }
819                ]
820            }
821        });
822        assert!(settings_has_rch_hook(&settings));
823    }
824
825    #[test]
826    fn test_settings_has_rch_hook_empty_hooks() {
827        let settings = json!({
828            "hooks": {
829                "PreToolUse": []
830            }
831        });
832        assert!(!settings_has_rch_hook(&settings));
833    }
834
835    #[test]
836    fn test_settings_has_rch_hook_no_hooks_section() {
837        let settings = json!({
838            "other_setting": true
839        });
840        assert!(!settings_has_rch_hook(&settings));
841    }
842
843    #[test]
844    fn test_settings_has_rch_hook_no_pre_tool_use() {
845        let settings = json!({
846            "hooks": {
847                "PostToolUse": [
848                    {"command": "rch"}
849                ]
850            }
851        });
852        assert!(!settings_has_rch_hook(&settings));
853    }
854
855    #[test]
856    fn test_settings_has_rch_hook_other_hook_only() {
857        let settings = json!({
858            "hooks": {
859                "PreToolUse": [
860                    {"command": "dcg", "description": "DCG hook"}
861                ]
862            }
863        });
864        assert!(!settings_has_rch_hook(&settings));
865    }
866
867    #[test]
868    fn test_settings_has_rch_hook_multiple_hooks() {
869        let settings = json!({
870            "hooks": {
871                "PreToolUse": [
872                    {"matcher": "Bash", "hooks": [{"type": "command", "command": "dcg"}]},
873                    {"matcher": "Bash", "hooks": [{"type": "command", "command": "rch"}]}
874                ]
875            }
876        });
877        assert!(settings_has_rch_hook(&settings));
878    }
879
880    // =========================================================================
881    // Add RCH Hook to Settings Tests
882    // =========================================================================
883
884    #[test]
885    fn test_add_rch_hook_to_empty_settings() {
886        let settings = json!({});
887        let result = add_rch_hook_to_settings(settings).unwrap();
888
889        assert!(settings_has_rch_hook(&result));
890
891        let hooks = result["hooks"]["PreToolUse"].as_array().unwrap();
892        assert_eq!(hooks.len(), 1);
893        // New format: check nested hooks array
894        assert_eq!(hooks[0]["matcher"], "Bash");
895        let inner_hooks = hooks[0]["hooks"].as_array().unwrap();
896        assert_eq!(inner_hooks[0]["command"], "rch");
897    }
898
899    #[test]
900    fn test_add_rch_hook_preserves_existing_settings() {
901        let settings = json!({
902            "other_setting": "preserved",
903            "number_setting": 42
904        });
905        let result = add_rch_hook_to_settings(settings).unwrap();
906
907        assert!(settings_has_rch_hook(&result));
908        assert_eq!(result["other_setting"], "preserved");
909        assert_eq!(result["number_setting"], 42);
910    }
911
912    #[test]
913    fn test_add_rch_hook_preserves_other_hooks() {
914        let settings = json!({
915            "hooks": {
916                "PreToolUse": [
917                    {"command": "dcg", "description": "DCG hook"}
918                ]
919            }
920        });
921        let result = add_rch_hook_to_settings(settings).unwrap();
922
923        let hooks = result["hooks"]["PreToolUse"].as_array().unwrap();
924        assert_eq!(hooks.len(), 2);
925
926        // Old format dcg hook should be preserved
927        assert!(hooks.iter().any(|h| h["command"].as_str() == Some("dcg")));
928        // New format rch hook should be added
929        assert!(settings_has_rch_hook(&result));
930    }
931
932    #[test]
933    fn test_add_rch_hook_preserves_other_hook_types() {
934        let settings = json!({
935            "hooks": {
936                "PreToolUse": [],
937                "PostToolUse": [
938                    {"command": "logger"}
939                ]
940            }
941        });
942        let result = add_rch_hook_to_settings(settings).unwrap();
943
944        // RCH should be in PreToolUse
945        assert!(settings_has_rch_hook(&result));
946
947        // PostToolUse should be preserved
948        let post_hooks = result["hooks"]["PostToolUse"].as_array().unwrap();
949        assert_eq!(post_hooks.len(), 1);
950        assert_eq!(post_hooks[0]["command"], "logger");
951    }
952
953    #[test]
954    fn test_add_rch_hook_to_non_object_fails() {
955        let settings = json!([1, 2, 3]);
956        let result = add_rch_hook_to_settings(settings);
957
958        assert!(result.is_err());
959        assert!(result.unwrap_err().to_string().contains("not an object"));
960    }
961
962    // =========================================================================
963    // Integration-Style Tests (with tempfile mock environment)
964    // =========================================================================
965
966    /// Helper to create a mock Claude Code environment in a temp directory.
967    struct MockClaudeEnv {
968        temp_dir: TempDir,
969    }
970
971    impl MockClaudeEnv {
972        fn new() -> Self {
973            Self {
974                temp_dir: TempDir::new().unwrap(),
975            }
976        }
977
978        fn claude_dir(&self) -> PathBuf {
979            self.temp_dir.path().join(".claude")
980        }
981
982        fn settings_path(&self) -> PathBuf {
983            self.claude_dir().join("settings.json")
984        }
985
986        fn create_claude_dir(&self) {
987            fs::create_dir_all(self.claude_dir()).unwrap();
988        }
989
990        fn write_settings(&self, settings: &Value) {
991            fs::write(
992                self.settings_path(),
993                serde_json::to_string_pretty(settings).unwrap(),
994            )
995            .unwrap();
996        }
997
998        fn read_settings(&self) -> Value {
999            let content = fs::read_to_string(self.settings_path()).unwrap();
1000            serde_json::from_str(&content).unwrap()
1001        }
1002    }
1003
1004    #[test]
1005    fn test_integration_install_hook_to_new_settings() {
1006        let env = MockClaudeEnv::new();
1007        env.create_claude_dir();
1008
1009        // Start with empty settings
1010        let settings = json!({});
1011        env.write_settings(&settings);
1012
1013        // Read, modify, write
1014        let content = fs::read_to_string(env.settings_path()).unwrap();
1015        let settings: Value = serde_json::from_str(&content).unwrap();
1016
1017        if !settings_has_rch_hook(&settings) {
1018            let modified = add_rch_hook_to_settings(settings).unwrap();
1019            let content = serde_json::to_string_pretty(&modified).unwrap();
1020            atomic_write(&env.settings_path(), content.as_bytes()).unwrap();
1021        }
1022
1023        // Verify
1024        let final_settings = env.read_settings();
1025        assert!(settings_has_rch_hook(&final_settings));
1026    }
1027
1028    #[test]
1029    fn test_integration_skip_if_already_installed() {
1030        let env = MockClaudeEnv::new();
1031        env.create_claude_dir();
1032
1033        // Start with RCH already installed (new format)
1034        let settings = json!({
1035            "hooks": {
1036                "PreToolUse": [
1037                    {
1038                        "matcher": "Bash",
1039                        "hooks": [{"type": "command", "command": "rch"}]
1040                    }
1041                ]
1042            }
1043        });
1044        env.write_settings(&settings);
1045
1046        // Check and don't modify
1047        let content = fs::read_to_string(env.settings_path()).unwrap();
1048        let settings: Value = serde_json::from_str(&content).unwrap();
1049
1050        let already_installed = settings_has_rch_hook(&settings);
1051        assert!(already_installed);
1052
1053        // Verify file unchanged (hooks array still has 1 entry)
1054        let final_settings = env.read_settings();
1055        let hooks = final_settings["hooks"]["PreToolUse"].as_array().unwrap();
1056        assert_eq!(hooks.len(), 1);
1057    }
1058
1059    #[test]
1060    fn test_integration_preserve_complex_settings() {
1061        let env = MockClaudeEnv::new();
1062        env.create_claude_dir();
1063
1064        // Complex existing settings
1065        let settings = json!({
1066            "appearance": {
1067                "theme": "dark",
1068                "fontSize": 14
1069            },
1070            "hooks": {
1071                "PreToolUse": [
1072                    {"command": "dcg", "description": "Guard"}
1073                ],
1074                "PostToolUse": [
1075                    {"command": "logger"}
1076                ]
1077            },
1078            "customPrompts": ["prompt1", "prompt2"],
1079            "enabled": true
1080        });
1081        env.write_settings(&settings);
1082
1083        // Add RCH hook
1084        let content = fs::read_to_string(env.settings_path()).unwrap();
1085        let settings: Value = serde_json::from_str(&content).unwrap();
1086        let modified = add_rch_hook_to_settings(settings).unwrap();
1087        let content = serde_json::to_string_pretty(&modified).unwrap();
1088        atomic_write(&env.settings_path(), content.as_bytes()).unwrap();
1089
1090        // Verify all other settings preserved
1091        let final_settings = env.read_settings();
1092
1093        assert_eq!(final_settings["appearance"]["theme"], "dark");
1094        assert_eq!(final_settings["appearance"]["fontSize"], 14);
1095        assert_eq!(final_settings["enabled"], true);
1096        assert_eq!(final_settings["customPrompts"].as_array().unwrap().len(), 2);
1097
1098        // DCG hook preserved (old format)
1099        let pre_hooks = final_settings["hooks"]["PreToolUse"].as_array().unwrap();
1100        assert!(pre_hooks.iter().any(|h| h["command"] == "dcg"));
1101        // RCH hook added (new format)
1102        assert!(settings_has_rch_hook(&final_settings));
1103
1104        // PostToolUse preserved
1105        let post_hooks = final_settings["hooks"]["PostToolUse"].as_array().unwrap();
1106        assert_eq!(post_hooks.len(), 1);
1107    }
1108
1109    #[test]
1110    fn test_integration_malformed_json_detection() {
1111        let env = MockClaudeEnv::new();
1112        env.create_claude_dir();
1113
1114        // Write malformed JSON
1115        fs::write(env.settings_path(), "{ invalid json }").unwrap();
1116
1117        // Try to parse - should fail
1118        let content = fs::read_to_string(env.settings_path()).unwrap();
1119        let result: Result<Value, _> = serde_json::from_str(&content);
1120
1121        assert!(result.is_err());
1122    }
1123
1124    #[test]
1125    fn test_integration_atomic_write_on_failure_leaves_original() {
1126        let env = MockClaudeEnv::new();
1127        env.create_claude_dir();
1128
1129        // Write valid settings
1130        let original = json!({"original": true});
1131        env.write_settings(&original);
1132
1133        // Try atomic write to a non-writable location (simulate failure)
1134        // Note: This test verifies the atomic_write function signature
1135        // A true failure test would require more complex setup
1136
1137        // Verify original is still intact after our read
1138        let final_settings = env.read_settings();
1139        assert_eq!(final_settings["original"], true);
1140    }
1141
1142    // =========================================================================
1143    // is_claude_code_installed Tests (uses real filesystem)
1144    // =========================================================================
1145
1146    #[test]
1147    fn test_is_claude_code_installed_returns_bool() {
1148        // This test just verifies the function exists and returns a bool
1149        // Actual result depends on whether Claude Code is installed on test machine
1150        let _ = is_claude_code_installed();
1151    }
1152}