1use domain::error::{CodeGraphError, Result};
2use serde_json::{json, Value};
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use super::setup_helpers::{
7 ensure_gitignore_entry, find_on_path, remove_gitignore_entry, resolve_settings_path,
8};
9use super::SetupArgs;
10use crate::project::find_project_root;
11
12pub(super) fn read_settings(path: &Path) -> Result<Value> {
15 if !path.exists() {
16 return Ok(json!({}));
17 }
18 let content = fs::read_to_string(path).map_err(|e| CodeGraphError::FileSystem {
19 path: path.to_path_buf(),
20 source: e,
21 })?;
22 serde_json::from_str(&content)
23 .map_err(|e| CodeGraphError::Other(format!("Invalid JSON in {}: {}", path.display(), e)))
24}
25
26pub(super) fn write_settings(path: &Path, value: &Value) -> Result<()> {
27 if let Some(parent) = path.parent() {
28 fs::create_dir_all(parent).map_err(|e| CodeGraphError::FileSystem {
29 path: parent.to_path_buf(),
30 source: e,
31 })?;
32 }
33 let mut content = serde_json::to_string_pretty(value)
34 .map_err(|e| CodeGraphError::Other(format!("Failed to serialize settings: {}", e)))?;
35 content.push('\n');
36 fs::write(path, content).map_err(|e| CodeGraphError::FileSystem {
37 path: path.to_path_buf(),
38 source: e,
39 })
40}
41
42pub(super) fn is_code_graph_hook(entry: &Value) -> bool {
43 if let Some(hooks) = entry.get("hooks").and_then(|h| h.as_array()) {
44 hooks.iter().any(|hook| {
45 hook.get("command")
46 .and_then(|c| c.as_str())
47 .map(|c| c.contains("code-graph"))
48 .unwrap_or(false)
49 })
50 } else {
51 false
52 }
53}
54
55pub(super) fn session_start_hook() -> Value {
56 json!({
57 "matcher": "startup",
58 "hooks": [
59 {
60 "type": "command",
61 "command": "code-graph index --incremental 2>/dev/null || true",
62 "timeout": 120
63 }
64 ]
65 })
66}
67
68pub(super) fn post_tool_use_hook() -> Value {
69 json!({
70 "matcher": "Edit|Write",
71 "hooks": [
72 {
73 "type": "command",
74 "command": "code-graph index --incremental --files \"$(cat | jq -r '.tool_input.file_path // empty')\" 2>/dev/null || true",
75 "timeout": 15
76 }
77 ]
78 })
79}
80
81fn hook_definitions() -> Vec<(&'static str, Value)> {
84 vec![
85 ("SessionStart", session_start_hook()),
86 ("PostToolUse", post_tool_use_hook()),
87 ]
88}
89
90#[derive(Debug, PartialEq)]
93enum HookStatus {
94 Installed,
95 Outdated,
96 Missing,
97}
98
99impl std::fmt::Display for HookStatus {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 match self {
102 HookStatus::Installed => write!(f, "installed"),
103 HookStatus::Outdated => write!(f, "outdated"),
104 HookStatus::Missing => write!(f, "missing"),
105 }
106 }
107}
108
109fn expected_command(hook_def: &Value) -> Option<&str> {
110 hook_def
111 .get("hooks")
112 .and_then(|h| h.as_array())
113 .and_then(|a| a.first())
114 .and_then(|h| h.get("command"))
115 .and_then(|c| c.as_str())
116}
117
118fn check_hook_status(settings: &Value, event: &str, expected: &Value) -> HookStatus {
119 let entries = match settings
120 .get("hooks")
121 .and_then(|h| h.get(event))
122 .and_then(|e| e.as_array())
123 {
124 Some(arr) => arr,
125 None => return HookStatus::Missing,
126 };
127
128 for entry in entries {
129 if is_code_graph_hook(entry) {
130 let found_cmd = entry
132 .get("hooks")
133 .and_then(|h| h.as_array())
134 .and_then(|a| a.first())
135 .and_then(|h| h.get("command"))
136 .and_then(|c| c.as_str());
137 let expected_cmd = expected_command(expected);
138 if found_cmd == expected_cmd {
139 return HookStatus::Installed;
140 } else {
141 return HookStatus::Outdated;
142 }
143 }
144 }
145
146 HookStatus::Missing
147}
148
149fn run_check(args: &SetupArgs, project_root: Option<&Path>) -> Result<()> {
150 let cg_binary = find_on_path("code-graph");
151 let jq_binary = find_on_path("jq");
152 let settings_path = resolve_settings_path(project_root, args.global)?;
153
154 println!(
155 "code-graph binary: {}",
156 match &cg_binary {
157 Some(p) => p.display().to_string(),
158 None => "not found".to_string(),
159 }
160 );
161 println!(
162 "jq: {}",
163 match &jq_binary {
164 Some(p) => p.display().to_string(),
165 None => "not found".to_string(),
166 }
167 );
168
169 let rel_path = if args.global {
170 "~/.claude/settings.json".to_string()
171 } else {
172 ".claude/settings.json".to_string()
173 };
174 println!("settings: {}", rel_path);
175
176 let settings = read_settings(&settings_path)?;
177 let defs = hook_definitions();
178 let mut all_installed = true;
179
180 for (event, expected) in &defs {
181 let status = check_hook_status(&settings, event, expected);
182 println!("{} hook: {}", event, status);
183 if status != HookStatus::Installed {
184 all_installed = false;
185 }
186 }
187
188 if all_installed {
189 println!("Status: all hooks installed");
190 Ok(())
191 } else {
192 Err(CodeGraphError::Other(
193 "Some hooks are missing or outdated".into(),
194 ))
195 }
196}
197
198fn run_install(args: &SetupArgs, project_root: Option<&Path>) -> Result<()> {
201 let settings_path = resolve_settings_path(project_root, args.global)?;
202 let mut settings = read_settings(&settings_path)?;
203
204 if settings.get("hooks").is_none() {
206 settings
207 .as_object_mut()
208 .unwrap()
209 .insert("hooks".to_string(), json!({}));
210 }
211
212 let defs = hook_definitions();
213 for (event, hook_def) in &defs {
214 let hooks_obj = settings.get_mut("hooks").unwrap().as_object_mut().unwrap();
215
216 if !hooks_obj.contains_key(*event) {
218 hooks_obj.insert(event.to_string(), json!([]));
219 }
220
221 let event_arr = hooks_obj.get_mut(*event).unwrap().as_array_mut().unwrap();
222
223 let existing_idx = event_arr.iter().position(is_code_graph_hook);
225
226 match existing_idx {
227 Some(idx) => {
228 event_arr[idx] = hook_def.clone();
230 }
231 None => {
232 event_arr.push(hook_def.clone());
234 }
235 }
236 }
237
238 write_settings(&settings_path, &settings)?;
239
240 if let Some(root) = project_root {
242 ensure_gitignore_entry(root)?;
243 } else if args.global {
244 println!("Not inside a git project — skipping .gitignore.");
245 }
246
247 if find_on_path("jq").is_none() {
249 println!("Warning: jq not found — PostToolUse hook will not extract file paths. Install jq for per-file incremental indexing.");
250 }
251
252 println!(
253 "Installed {} hooks to {}",
254 defs.len(),
255 settings_path.display()
256 );
257 Ok(())
258}
259
260fn run_remove(args: &SetupArgs, project_root: Option<&Path>) -> Result<()> {
263 let settings_path = resolve_settings_path(project_root, args.global)?;
264 let mut settings = read_settings(&settings_path)?;
265
266 if let Some(hooks_obj) = settings.get_mut("hooks").and_then(|h| h.as_object_mut()) {
267 let event_keys: Vec<String> = hooks_obj.keys().cloned().collect();
268 for event_key in event_keys {
269 if let Some(arr) = hooks_obj.get_mut(&event_key).and_then(|v| v.as_array_mut()) {
270 arr.retain(|entry| !is_code_graph_hook(entry));
271 if arr.is_empty() {
272 hooks_obj.remove(&event_key);
273 }
274 }
275 }
276 if hooks_obj.is_empty() {
277 settings.as_object_mut().unwrap().remove("hooks");
278 }
279 }
280
281 write_settings(&settings_path, &settings)?;
282
283 if args.clean || args.purge {
285 if let Some(root) = project_root {
286 remove_gitignore_entry(root)?;
287 }
288 }
289
290 if args.purge {
292 if let Some(root) = project_root {
293 let data_dir = root.join(".code-graph");
294 if data_dir.is_dir() {
295 let meta =
297 fs::symlink_metadata(&data_dir).map_err(|e| CodeGraphError::FileSystem {
298 path: data_dir.clone(),
299 source: e,
300 })?;
301 if meta.file_type().is_symlink() {
302 return Err(CodeGraphError::Other(format!(
303 "{} is a symlink — refusing to purge",
304 data_dir.display()
305 )));
306 }
307 fs::remove_dir_all(&data_dir).map_err(|e| CodeGraphError::FileSystem {
308 path: data_dir,
309 source: e,
310 })?;
311 }
312 }
313 }
314
315 println!("Removed code-graph hooks from {}", settings_path.display());
316 Ok(())
317}
318
319fn find_project_root_optional() -> Option<PathBuf> {
322 let cwd = std::env::current_dir().ok()?;
323 find_project_root(&cwd).ok()
324}
325
326pub fn run_setup(args: &SetupArgs) -> Result<()> {
327 let project_root = find_project_root_optional();
328 if args.check {
329 return run_check(args, project_root.as_deref());
330 }
331 if args.remove {
332 return run_remove(args, project_root.as_deref());
333 }
334 let platform = args.platform.as_deref().ok_or_else(|| {
336 CodeGraphError::Other("platform required: code-graph setup claude".into())
337 })?;
338 if platform != "claude" {
339 return Err(CodeGraphError::Other(format!(
340 "Unsupported platform '{}'. Supported: claude",
341 platform
342 )));
343 }
344 run_install(args, project_root.as_deref())
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use serde_json::Value;
351 use tempfile::tempdir;
352
353 #[test]
356 fn read_settings_returns_empty_for_missing_file() {
357 let dir = tempdir().unwrap();
358 let path = dir.path().join("nonexistent.json");
359 let result = read_settings(&path).unwrap();
360 assert!(result.is_object());
361 assert_eq!(result.as_object().unwrap().len(), 0);
362 }
363
364 #[test]
365 fn read_settings_parses_existing_json() {
366 let dir = tempdir().unwrap();
367 let path = dir.path().join("settings.json");
368 let content = r#"{"hooks": {"SessionStart": []}, "theme": "dark"}"#;
369 fs::write(&path, content).unwrap();
370
371 let result = read_settings(&path).unwrap();
372 assert!(result.is_object());
373 assert!(result.get("hooks").is_some());
374 assert!(result.get("theme").is_some());
375 assert_eq!(result["theme"], Value::String("dark".into()));
376 }
377
378 #[test]
379 fn read_settings_errors_on_invalid_json() {
380 let dir = tempdir().unwrap();
381 let path = dir.path().join("broken.json");
382 fs::write(&path, "{ not valid json !!").unwrap();
383
384 let err = read_settings(&path).unwrap_err();
385 let msg = format!("{}", err);
386 assert!(msg.contains("Invalid JSON"));
387 assert!(msg.contains("broken.json"));
388 }
389
390 #[test]
391 fn write_settings_creates_parent_dirs() {
392 let dir = tempdir().unwrap();
393 let path = dir.path().join("deep").join("nested").join("settings.json");
394 let value = json!({"key": "value"});
395
396 write_settings(&path, &value).unwrap();
397 assert!(path.exists());
398 let content = fs::read_to_string(&path).unwrap();
399 let parsed: Value = serde_json::from_str(&content).unwrap();
400 assert_eq!(parsed["key"], Value::String("value".into()));
401 }
402
403 #[test]
404 fn write_settings_preserves_key_order() {
405 let dir = tempdir().unwrap();
406 let path = dir.path().join("settings.json");
407 let value = json!({"alpha": 1, "beta": 2, "gamma": 3});
408
409 write_settings(&path, &value).unwrap();
410 let content = fs::read_to_string(&path).unwrap();
411 let parsed: Value = serde_json::from_str(&content).unwrap();
412 let keys: Vec<&str> = parsed
413 .as_object()
414 .unwrap()
415 .keys()
416 .map(|k| k.as_str())
417 .collect();
418 assert_eq!(keys, vec!["alpha", "beta", "gamma"]);
419 }
420
421 #[test]
422 fn is_code_graph_hook_identifies_our_hooks() {
423 let entry = json!({
424 "matcher": "Edit|Write",
425 "hooks": [{"type": "command", "command": "code-graph index --incremental"}]
426 });
427 assert!(is_code_graph_hook(&entry));
428 }
429
430 #[test]
431 fn is_code_graph_hook_ignores_other_hooks() {
432 let entry = json!({
433 "matcher": "Edit|Write",
434 "hooks": [{"type": "command", "command": "echo hello"}]
435 });
436 assert!(!is_code_graph_hook(&entry));
437 }
438
439 #[test]
440 fn hook_definitions_have_correct_structure() {
441 let ss = session_start_hook();
442 assert_eq!(ss["matcher"], "startup");
443 let ss_hooks = ss["hooks"].as_array().unwrap();
444 assert_eq!(ss_hooks.len(), 1);
445 assert_eq!(ss_hooks[0]["type"], "command");
446 assert!(ss_hooks[0]["command"]
447 .as_str()
448 .unwrap()
449 .contains("code-graph index --incremental"));
450 assert_eq!(ss_hooks[0]["timeout"], 120);
451
452 let ptu = post_tool_use_hook();
453 assert_eq!(ptu["matcher"], "Edit|Write");
454 let ptu_hooks = ptu["hooks"].as_array().unwrap();
455 assert_eq!(ptu_hooks.len(), 1);
456 assert!(ptu_hooks[0]["command"]
457 .as_str()
458 .unwrap()
459 .contains("code-graph index --incremental"));
460 assert_eq!(ptu_hooks[0]["timeout"], 15);
461 }
462
463 fn make_setup_args(
466 platform: Option<&str>,
467 global: bool,
468 check: bool,
469 remove: bool,
470 clean: bool,
471 purge: bool,
472 ) -> SetupArgs {
473 SetupArgs {
474 platform: platform.map(String::from),
475 global,
476 check,
477 remove,
478 clean,
479 purge,
480 }
481 }
482
483 #[test]
484 fn install_creates_hooks_in_empty_settings() {
485 let dir = tempdir().unwrap();
486 let root = dir.path();
487 fs::create_dir(root.join(".git")).unwrap();
488 let args = make_setup_args(Some("claude"), false, false, false, false, false);
489
490 run_install(&args, Some(root)).unwrap();
491
492 let settings_path = root.join(".claude").join("settings.json");
493 let settings: Value =
494 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
495 assert!(settings["hooks"]["SessionStart"].as_array().unwrap().len() == 1);
496 assert!(settings["hooks"]["PostToolUse"].as_array().unwrap().len() == 1);
497 assert!(is_code_graph_hook(&settings["hooks"]["SessionStart"][0]));
498 assert!(is_code_graph_hook(&settings["hooks"]["PostToolUse"][0]));
499 }
500
501 #[test]
502 fn install_preserves_existing_settings() {
503 let dir = tempdir().unwrap();
504 let root = dir.path();
505 let settings_path = root.join(".claude").join("settings.json");
506 fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
507 fs::write(
508 &settings_path,
509 r#"{"env": {"DEBUG": "1"}, "permissions": {"allow": ["Read"]}}"#,
510 )
511 .unwrap();
512
513 let args = make_setup_args(Some("claude"), false, false, false, false, false);
514 run_install(&args, Some(root)).unwrap();
515
516 let settings: Value =
517 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
518 assert_eq!(settings["env"]["DEBUG"], "1");
519 assert!(settings["permissions"]["allow"]
520 .as_array()
521 .unwrap()
522 .contains(&Value::String("Read".into())));
523 assert!(settings.get("hooks").is_some());
524 }
525
526 #[test]
527 fn install_preserves_existing_non_codegraph_hooks() {
528 let dir = tempdir().unwrap();
529 let root = dir.path();
530 let settings_path = root.join(".claude").join("settings.json");
531 fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
532 let existing = json!({
533 "hooks": {
534 "SessionStart": [
535 {"matcher": "startup", "hooks": [{"type": "command", "command": "echo hello"}]}
536 ]
537 }
538 });
539 fs::write(
540 &settings_path,
541 serde_json::to_string_pretty(&existing).unwrap(),
542 )
543 .unwrap();
544
545 let args = make_setup_args(Some("claude"), false, false, false, false, false);
546 run_install(&args, Some(root)).unwrap();
547
548 let settings: Value =
549 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
550 let ss_arr = settings["hooks"]["SessionStart"].as_array().unwrap();
551 assert_eq!(
552 ss_arr.len(),
553 2,
554 "should have both original and code-graph hook"
555 );
556 }
557
558 #[test]
559 fn install_idempotent_no_duplicates() {
560 let dir = tempdir().unwrap();
561 let root = dir.path();
562 let args = make_setup_args(Some("claude"), false, false, false, false, false);
563
564 run_install(&args, Some(root)).unwrap();
565 run_install(&args, Some(root)).unwrap();
566
567 let settings_path = root.join(".claude").join("settings.json");
568 let settings: Value =
569 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
570 assert_eq!(
571 settings["hooks"]["SessionStart"].as_array().unwrap().len(),
572 1
573 );
574 assert_eq!(
575 settings["hooks"]["PostToolUse"].as_array().unwrap().len(),
576 1
577 );
578 }
579
580 #[test]
581 fn install_updates_outdated_hooks() {
582 let dir = tempdir().unwrap();
583 let root = dir.path();
584 let settings_path = root.join(".claude").join("settings.json");
585 fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
586 let old = json!({
587 "hooks": {
588 "SessionStart": [
589 {"matcher": "startup", "hooks": [{"type": "command", "command": "code-graph old-command", "timeout": 60}]}
590 ]
591 }
592 });
593 fs::write(&settings_path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
594
595 let args = make_setup_args(Some("claude"), false, false, false, false, false);
596 run_install(&args, Some(root)).unwrap();
597
598 let settings: Value =
599 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
600 let ss_arr = settings["hooks"]["SessionStart"].as_array().unwrap();
601 assert_eq!(ss_arr.len(), 1, "should replace in place, not duplicate");
602 let cmd = ss_arr[0]["hooks"][0]["command"].as_str().unwrap();
603 assert!(cmd.contains("--incremental"), "should have updated command");
604 }
605
606 #[test]
607 fn install_adds_gitignore_entry() {
608 let dir = tempdir().unwrap();
609 let root = dir.path();
610 let args = make_setup_args(Some("claude"), false, false, false, false, false);
611
612 run_install(&args, Some(root)).unwrap();
613
614 let gitignore = fs::read_to_string(root.join(".gitignore")).unwrap();
615 assert!(gitignore.contains(".code-graph/"));
616 }
617
618 #[test]
621 fn check_all_installed_reports_ok() {
622 let dir = tempdir().unwrap();
623 let root = dir.path();
624 let install_args = make_setup_args(Some("claude"), false, false, false, false, false);
626 run_install(&install_args, Some(root)).unwrap();
627
628 let check_args = make_setup_args(None, false, true, false, false, false);
630 let result = run_check(&check_args, Some(root));
631 assert!(result.is_ok());
632 }
633
634 #[test]
635 fn check_missing_hooks_reports_missing() {
636 let dir = tempdir().unwrap();
637 let root = dir.path();
638 let settings_path = root.join(".claude").join("settings.json");
640 fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
641 fs::write(&settings_path, "{}").unwrap();
642
643 let check_args = make_setup_args(None, false, true, false, false, false);
644 let result = run_check(&check_args, Some(root));
645 assert!(result.is_err());
646 }
647
648 #[test]
649 fn check_outdated_hook_reports_outdated() {
650 let dir = tempdir().unwrap();
651 let root = dir.path();
652 let settings_path = root.join(".claude").join("settings.json");
653 fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
654 let old = json!({
655 "hooks": {
656 "SessionStart": [
657 {"matcher": "startup", "hooks": [{"type": "command", "command": "code-graph old-cmd", "timeout": 60}]}
658 ],
659 "PostToolUse": [post_tool_use_hook()]
660 }
661 });
662 fs::write(&settings_path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
663
664 let check_args = make_setup_args(None, false, true, false, false, false);
665 let result = run_check(&check_args, Some(root));
666 assert!(result.is_err(), "outdated hook should report error");
667 }
668
669 #[test]
670 fn check_hook_status_installed() {
671 let settings = json!({
672 "hooks": {
673 "SessionStart": [session_start_hook()]
674 }
675 });
676 let status = check_hook_status(&settings, "SessionStart", &session_start_hook());
677 assert_eq!(status, HookStatus::Installed);
678 }
679
680 #[test]
681 fn check_hook_status_missing() {
682 let settings = json!({});
683 let status = check_hook_status(&settings, "SessionStart", &session_start_hook());
684 assert_eq!(status, HookStatus::Missing);
685 }
686
687 #[test]
688 fn check_hook_status_outdated() {
689 let settings = json!({
690 "hooks": {
691 "SessionStart": [
692 {"matcher": "startup", "hooks": [{"type": "command", "command": "code-graph old-cmd"}]}
693 ]
694 }
695 });
696 let status = check_hook_status(&settings, "SessionStart", &session_start_hook());
697 assert_eq!(status, HookStatus::Outdated);
698 }
699
700 #[test]
703 fn remove_filters_code_graph_hooks() {
704 let dir = tempdir().unwrap();
705 let root = dir.path();
706 let settings_path = root.join(".claude").join("settings.json");
707 fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
708 let settings = json!({
709 "hooks": {
710 "SessionStart": [
711 {"matcher": "startup", "hooks": [{"type": "command", "command": "echo hello"}]},
712 session_start_hook()
713 ]
714 }
715 });
716 fs::write(
717 &settings_path,
718 serde_json::to_string_pretty(&settings).unwrap(),
719 )
720 .unwrap();
721
722 let args = make_setup_args(None, false, false, true, false, false);
723 run_remove(&args, Some(root)).unwrap();
724
725 let result: Value =
726 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
727 let arr = result["hooks"]["SessionStart"].as_array().unwrap();
728 assert_eq!(arr.len(), 1, "should only have non-code-graph hook left");
729 assert!(!is_code_graph_hook(&arr[0]));
730 }
731
732 #[test]
733 fn remove_cleans_empty_event_arrays() {
734 let dir = tempdir().unwrap();
735 let root = dir.path();
736 let settings_path = root.join(".claude").join("settings.json");
737 fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
738 let settings = json!({
739 "hooks": {
740 "SessionStart": [session_start_hook()],
741 "PostToolUse": [
742 {"matcher": "Edit", "hooks": [{"type": "command", "command": "echo other"}]}
743 ]
744 }
745 });
746 fs::write(
747 &settings_path,
748 serde_json::to_string_pretty(&settings).unwrap(),
749 )
750 .unwrap();
751
752 let args = make_setup_args(None, false, false, true, false, false);
753 run_remove(&args, Some(root)).unwrap();
754
755 let result: Value =
756 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
757 assert!(
758 result["hooks"].get("SessionStart").is_none(),
759 "empty event should be removed"
760 );
761 assert!(result["hooks"]["PostToolUse"].as_array().unwrap().len() == 1);
762 }
763
764 #[test]
765 fn remove_cleans_empty_hooks_object() {
766 let dir = tempdir().unwrap();
767 let root = dir.path();
768 let install_args = make_setup_args(Some("claude"), false, false, false, false, false);
770 run_install(&install_args, Some(root)).unwrap();
771
772 let remove_args = make_setup_args(None, false, false, true, false, false);
773 run_remove(&remove_args, Some(root)).unwrap();
774
775 let settings_path = root.join(".claude").join("settings.json");
776 let result: Value =
777 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
778 assert!(
779 result.get("hooks").is_none(),
780 "empty hooks object should be removed"
781 );
782 }
783
784 #[test]
785 fn remove_noop_when_no_hooks() {
786 let dir = tempdir().unwrap();
787 let root = dir.path();
788 let settings_path = root.join(".claude").join("settings.json");
789 fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
790 fs::write(&settings_path, "{}").unwrap();
791
792 let args = make_setup_args(None, false, false, true, false, false);
793 let result = run_remove(&args, Some(root));
794 assert!(result.is_ok());
795 }
796
797 #[test]
798 fn remove_with_clean_removes_gitignore() {
799 let dir = tempdir().unwrap();
800 let root = dir.path();
801
802 let install_args = make_setup_args(Some("claude"), false, false, false, false, false);
804 run_install(&install_args, Some(root)).unwrap();
805 assert!(fs::read_to_string(root.join(".gitignore"))
806 .unwrap()
807 .contains(".code-graph/"));
808
809 let remove_args = make_setup_args(None, false, false, true, true, false);
811 run_remove(&remove_args, Some(root)).unwrap();
812
813 let gitignore = fs::read_to_string(root.join(".gitignore")).unwrap();
814 assert!(!gitignore.contains(".code-graph/"));
815 }
816
817 #[test]
818 fn remove_with_purge_deletes_data_dir() {
819 let dir = tempdir().unwrap();
820 let root = dir.path();
821
822 let data_dir = root.join(".code-graph");
824 fs::create_dir(&data_dir).unwrap();
825 fs::write(data_dir.join("graph.db"), "test").unwrap();
826 assert!(data_dir.is_dir());
827
828 let install_args = make_setup_args(Some("claude"), false, false, false, false, false);
830 run_install(&install_args, Some(root)).unwrap();
831
832 let remove_args = make_setup_args(None, false, false, true, true, true);
833 run_remove(&remove_args, Some(root)).unwrap();
834
835 assert!(!data_dir.exists(), ".code-graph/ should be deleted");
836 }
837
838 #[test]
839 fn install_unknown_platform_errors() {
840 let dir = tempdir().unwrap();
841 let root = dir.path();
842 let args = make_setup_args(Some("cursor"), false, false, false, false, false);
843 let err = run_install_via_dispatch(&args, Some(root));
844 assert!(err.is_err());
845 let msg = format!("{}", err.unwrap_err());
846 assert!(msg.contains("Unsupported platform"));
847 assert!(msg.contains("claude"));
848 }
849
850 fn run_install_via_dispatch(args: &SetupArgs, project_root: Option<&Path>) -> Result<()> {
851 let platform = args
852 .platform
853 .as_deref()
854 .ok_or_else(|| CodeGraphError::Other("platform required".into()))?;
855 if platform != "claude" {
856 return Err(CodeGraphError::Other(format!(
857 "Unsupported platform '{}'. Supported: claude",
858 platform
859 )));
860 }
861 run_install(args, project_root)
862 }
863
864 #[test]
865 fn remove_preserves_other_settings() {
866 let dir = tempdir().unwrap();
867 let root = dir.path();
868 let settings_path = root.join(".claude").join("settings.json");
869 fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
870 let settings = json!({
871 "env": {"DEBUG": "1"},
872 "hooks": {
873 "SessionStart": [session_start_hook()]
874 }
875 });
876 fs::write(
877 &settings_path,
878 serde_json::to_string_pretty(&settings).unwrap(),
879 )
880 .unwrap();
881
882 let args = make_setup_args(None, false, false, true, false, false);
883 run_remove(&args, Some(root)).unwrap();
884
885 let result: Value =
886 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
887 assert_eq!(result["env"]["DEBUG"], "1");
888 }
889
890 #[test]
893 fn full_install_check_remove_cycle() {
894 let dir = tempdir().unwrap();
895 let root = dir.path();
896 fs::create_dir(root.join(".git")).unwrap();
897
898 let install_args = make_setup_args(Some("claude"), false, false, false, false, false);
900 run_install(&install_args, Some(root)).unwrap();
901
902 let settings_path = root.join(".claude").join("settings.json");
904 let settings: Value =
905 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
906 assert_eq!(
907 settings["hooks"]["SessionStart"].as_array().unwrap().len(),
908 1
909 );
910 assert_eq!(
911 settings["hooks"]["PostToolUse"].as_array().unwrap().len(),
912 1
913 );
914
915 let check_args = make_setup_args(None, false, true, false, false, false);
917 assert!(run_check(&check_args, Some(root)).is_ok());
918
919 let remove_args = make_setup_args(None, false, false, true, false, false);
921 run_remove(&remove_args, Some(root)).unwrap();
922
923 let check_args2 = make_setup_args(None, false, true, false, false, false);
925 assert!(run_check(&check_args2, Some(root)).is_err());
926 }
927
928 #[test]
929 fn install_on_existing_settings_preserves_other_hooks() {
930 let dir = tempdir().unwrap();
931 let root = dir.path();
932 let settings_path = root.join(".claude").join("settings.json");
933 fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
934
935 let existing = json!({
937 "hooks": {
938 "PreToolUse": [
939 {"matcher": "Bash", "hooks": [{"type": "command", "command": "echo pre-bash"}]}
940 ]
941 }
942 });
943 fs::write(
944 &settings_path,
945 serde_json::to_string_pretty(&existing).unwrap(),
946 )
947 .unwrap();
948
949 let args = make_setup_args(Some("claude"), false, false, false, false, false);
951 run_install(&args, Some(root)).unwrap();
952
953 let settings: Value =
955 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
956 assert!(settings["hooks"]["PreToolUse"].as_array().unwrap().len() == 1);
957 assert!(settings["hooks"]["SessionStart"].as_array().unwrap().len() == 1);
958 assert!(settings["hooks"]["PostToolUse"].as_array().unwrap().len() == 1);
959 }
960
961 #[test]
962 fn idempotent_install_no_duplicates_integration() {
963 let dir = tempdir().unwrap();
964 let root = dir.path();
965 let args = make_setup_args(Some("claude"), false, false, false, false, false);
966
967 run_install(&args, Some(root)).unwrap();
969 run_install(&args, Some(root)).unwrap();
970 run_install(&args, Some(root)).unwrap();
971
972 let settings_path = root.join(".claude").join("settings.json");
973 let settings: Value =
974 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
975 assert_eq!(
976 settings["hooks"]["SessionStart"].as_array().unwrap().len(),
977 1
978 );
979 assert_eq!(
980 settings["hooks"]["PostToolUse"].as_array().unwrap().len(),
981 1
982 );
983
984 let gitignore = fs::read_to_string(root.join(".gitignore")).unwrap();
986 assert_eq!(gitignore.matches(".code-graph/").count(), 1);
987 }
988
989 #[test]
990 fn purge_deletes_data_directory_integration() {
991 let dir = tempdir().unwrap();
992 let root = dir.path();
993
994 let data_dir = root.join(".code-graph");
996 fs::create_dir_all(&data_dir).unwrap();
997 fs::write(data_dir.join("graph.db"), "test data").unwrap();
998 fs::write(data_dir.join("meta.json"), "{}").unwrap();
999
1000 let install_args = make_setup_args(Some("claude"), false, false, false, false, false);
1002 run_install(&install_args, Some(root)).unwrap();
1003
1004 let remove_args = make_setup_args(None, false, false, true, true, true);
1006 run_remove(&remove_args, Some(root)).unwrap();
1007
1008 assert!(!data_dir.exists(), ".code-graph/ directory should be gone");
1009 let gitignore = fs::read_to_string(root.join(".gitignore")).unwrap();
1010 assert!(
1011 !gitignore.contains(".code-graph/"),
1012 ".gitignore entry should be removed"
1013 );
1014 }
1015}