Skip to main content

the_code_graph_cli/commands/
setup.rs

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
12// ── Settings JSON management (T02) ──────────────────────────────────────────
13
14pub(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
81// ── Hook definitions mapping ────────────────────────────────────────────────
82
83fn hook_definitions() -> Vec<(&'static str, Value)> {
84    vec![
85        ("SessionStart", session_start_hook()),
86        ("PostToolUse", post_tool_use_hook()),
87    ]
88}
89
90// ── Check mode (T05) ────────────────────────────────────────────────────────
91
92#[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            // Found a code-graph hook — check if command matches
131            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
198// ── Install mode (T04) ──────────────────────────────────────────────────────
199
200fn 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    // Ensure hooks object exists
205    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        // Ensure event array exists
217        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        // Find existing code-graph entry
224        let existing_idx = event_arr.iter().position(is_code_graph_hook);
225
226        match existing_idx {
227            Some(idx) => {
228                // Update in place (idempotent)
229                event_arr[idx] = hook_def.clone();
230            }
231            None => {
232                // Append new entry
233                event_arr.push(hook_def.clone());
234            }
235        }
236    }
237
238    write_settings(&settings_path, &settings)?;
239
240    // Manage .gitignore
241    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    // Check jq availability
248    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
260// ── Remove mode (T06) ───────────────────────────────────────────────────────
261
262fn 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    // --clean or --purge → remove .gitignore entry
284    if args.clean || args.purge {
285        if let Some(root) = project_root {
286            remove_gitignore_entry(root)?;
287        }
288    }
289
290    // --purge → delete .code-graph/ directory
291    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                // Refuse to follow symlinks to avoid deleting unrelated directories
296                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
319// ── Dispatcher (T07 stub — wired in T07) ────────────────────────────────────
320
321fn 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    // Install mode — platform required
335    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    // ── T02 tests: settings JSON management ─────────────────────────────────
354
355    #[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    // ── T04 tests: install mode ─────────────────────────────────────────────
464
465    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    // ── T05 tests: check mode ───────────────────────────────────────────────
619
620    #[test]
621    fn check_all_installed_reports_ok() {
622        let dir = tempdir().unwrap();
623        let root = dir.path();
624        // Install first
625        let install_args = make_setup_args(Some("claude"), false, false, false, false, false);
626        run_install(&install_args, Some(root)).unwrap();
627
628        // Then check
629        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        // Create empty settings
639        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    // ── T06 tests: remove mode ──────────────────────────────────────────────
701
702    #[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        // Install then remove
769        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        // Set up: install hooks + gitignore entry
803        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        // Remove with --clean
810        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        // Create .code-graph/ directory
823        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        // Install then purge
829        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    // ── T07 tests: integration ──────────────────────────────────────────────
891
892    #[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        // Install
899        let install_args = make_setup_args(Some("claude"), false, false, false, false, false);
900        run_install(&install_args, Some(root)).unwrap();
901
902        // Verify settings JSON has correct structure
903        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        // Check reports installed
916        let check_args = make_setup_args(None, false, true, false, false, false);
917        assert!(run_check(&check_args, Some(root)).is_ok());
918
919        // Remove
920        let remove_args = make_setup_args(None, false, false, true, false, false);
921        run_remove(&remove_args, Some(root)).unwrap();
922
923        // Check reports missing
924        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        // Pre-populate with a non-code-graph hook
936        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        // Install
950        let args = make_setup_args(Some("claude"), false, false, false, false, false);
951        run_install(&args, Some(root)).unwrap();
952
953        // Verify both old and new hooks present
954        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        // Install three times
968        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        // .gitignore should also have exactly one entry
985        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        // Create .code-graph/ with content
995        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        // Install
1001        let install_args = make_setup_args(Some("claude"), false, false, false, false, false);
1002        run_install(&install_args, Some(root)).unwrap();
1003
1004        // Purge
1005        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}