1use 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#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum HookResult {
18 AlreadyInstalled,
20 Installed,
22 Skipped(String),
24 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
39fn claude_code_settings_path() -> Option<PathBuf> {
41 dirs::home_dir().map(|h| h.join(".claude").join("settings.json"))
42}
43
44fn 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
61fn claude_code_dir() -> Option<PathBuf> {
63 dirs::home_dir().map(|h| h.join(".claude"))
64}
65
66pub fn is_claude_code_installed() -> bool {
70 claude_code_dir().map(|p| p.exists()).unwrap_or(false)
71}
72
73#[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 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 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 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#[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
126fn 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
146fn 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 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 guard.0 = None;
182
183 Ok(())
184}
185
186fn 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
205pub fn verify_and_install_claude_code_hook() -> Result<HookResult> {
223 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 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 }
246 Err(e) => {
247 warn!(
249 "Could not check hook status: {}, attempting installation",
250 e
251 );
252 }
253 }
254
255 let settings_path = claude_code_settings_path()
257 .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
258
259 if let Some(parent) = settings_path.parent() {
261 fs::create_dir_all(parent)?;
262 }
263
264 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 if settings_path.exists()
283 && let Err(e) = create_backup(&settings_path)
284 {
285 warn!("Could not create backup: {}", e);
286 }
288
289 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 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#[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 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 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#[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 #[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 let foreign = json!({
449 "hooks": { "PreToolUse": [{ "hooks": [{ "command": "archive --now" }] }] }
450 });
451 assert_eq!(rch_pretooluse_hook_command(&foreign), None);
452 }
453
454 #[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 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 #[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 unsafe { std::env::set_var(key, value) };
526 }
527
528 fn remove_env(key: &str) {
529 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 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 #[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 #[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 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 #[test]
771 fn test_settings_has_rch_hook_with_object_hook() {
772 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 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 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 #[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 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 assert!(hooks.iter().any(|h| h["command"].as_str() == Some("dcg")));
928 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 assert!(settings_has_rch_hook(&result));
946
947 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 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 let settings = json!({});
1011 env.write_settings(&settings);
1012
1013 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 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 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 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 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 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 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 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 let pre_hooks = final_settings["hooks"]["PreToolUse"].as_array().unwrap();
1100 assert!(pre_hooks.iter().any(|h| h["command"] == "dcg"));
1101 assert!(settings_has_rch_hook(&final_settings));
1103
1104 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 fs::write(env.settings_path(), "{ invalid json }").unwrap();
1116
1117 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 let original = json!({"original": true});
1131 env.write_settings(&original);
1132
1133 let final_settings = env.read_settings();
1139 assert_eq!(final_settings["original"], true);
1140 }
1141
1142 #[test]
1147 fn test_is_claude_code_installed_returns_bool() {
1148 let _ = is_claude_code_installed();
1151 }
1152}