Skip to main content

difflore_cli/mcp_install/
mod.rs

1//! MCP installer: registers difflore as an MCP server with every detected
2//! AI coding tool, in one shot. Public entry points are `install_all`,
3//! `uninstall_all`, `status`, `collect_status_snapshot`,
4//! `detect_install_drift`, and `maybe_print_mcp_hint`.
5//!
6//! Detect + install + uninstall are all driven by the single `AGENTS` table in
7//! `registry.rs` (one [`registry::AgentSpec`] row per surface). The drivers
8//! delegate to the leaf format engines — `json_config.rs`, `goose_yaml.rs`,
9//! `hooks_install.rs` — and shared path / record helpers live in `common.rs`.
10//! Adding an agent means adding one `AGENTS` row, not touching a probe table,
11//! a per-agent install fn, an install/uninstall dispatch list, and the
12//! name/UX maps.
13
14mod common;
15mod diagnosis;
16mod goose_yaml;
17mod hooks_install;
18mod install;
19mod json_config;
20mod manifest;
21mod registry;
22mod snapshot;
23mod status_display;
24mod types;
25mod uninstall;
26
27pub use install::{install_all, update_all};
28pub use snapshot::{collect_status_snapshot, collect_status_snapshot_with_runtime_probe};
29pub use status_display::{
30    detect_install_drift, detect_install_repair_targets, maybe_print_mcp_hint, status,
31};
32pub use types::{
33    CanonicalRecordState, CanonicalRecordStatus, InstallState, McpClientStatus, McpRuntimeProbe,
34    McpStatusDiagnosis, McpStatusSnapshot, RuntimeProbeState, Status, TargetOutcome, TargetStatus,
35};
36pub use uninstall::uninstall_all;
37
38#[cfg(test)]
39mod test_util {
40    use std::path::PathBuf;
41
42    pub(super) fn tmp_settings_path() -> (tempfile::TempDir, PathBuf) {
43        tmp_named_path("settings.json")
44    }
45
46    pub(super) fn tmp_named_path(filename: &str) -> (tempfile::TempDir, PathBuf) {
47        let dir = tempfile::TempDir::new().expect("tempdir");
48        let path = dir.path().join(filename);
49        (dir, path)
50    }
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use super::{
57        common::{self, canonical_target_key},
58        diagnosis::{
59            client_name_for_surface, diagnose_status_snapshot, install_repair_targets_for_snapshot,
60        },
61        install::{
62            failed_outcome_names, install_outcome_verb, outcome_already_installed,
63            outcome_client_names, should_write_canonical_record,
64        },
65        registry::AGENTS,
66        snapshot::collect_client_statuses_from_agents,
67    };
68    use std::{collections::BTreeSet, fs};
69
70    // ── Registry single-table guard rails (Item ④) ─────────────────────────
71
72    #[test]
73    fn agents_table_orders_claude_then_claude_hooks_then_codex_first() {
74        // R4: `collect_agent_statuses` relies on this row order (Claude Code →
75        // Claude Code hooks → Codex first), now encoded directly in the table
76        // instead of a manual reshuffle. Guard the first three names.
77        let first_three: Vec<&str> = AGENTS.iter().take(3).map(|spec| spec.name).collect();
78        assert_eq!(
79            first_three,
80            vec!["Claude Code", "Claude Code hooks", "Codex"]
81        );
82    }
83
84    #[test]
85    fn every_agent_surface_resolves_to_a_known_client() {
86        // R1: surface `name` strings are load-bearing — every one must resolve
87        // through `client_name_for_surface` to a real client (never the
88        // "unknown client" sentinel), or roll-up and record-matching silently
89        // break.
90        for spec in AGENTS {
91            assert_ne!(
92                client_name_for_surface(spec.name),
93                "unknown client",
94                "surface {:?} did not resolve to a known client",
95                spec.name
96            );
97            // The display client on the row must agree with the derived one.
98            assert_eq!(
99                client_name_for_surface(spec.name),
100                spec.client,
101                "surface {:?} client mismatch",
102                spec.name
103            );
104        }
105    }
106
107    #[test]
108    fn agents_table_keeps_legacy_surface_name_set() {
109        // R1 mitigation: pin the exact surface-name set so a typo (e.g.
110        // "Gemini CLI" vs "Gemini") can't slip in and desync the canonical
111        // record / client roll-up.
112        let names: BTreeSet<&str> = AGENTS.iter().map(|spec| spec.name).collect();
113        let expected: BTreeSet<&str> = [
114            "Claude Code",
115            "Claude Code hooks",
116            "Codex",
117            "Cursor",
118            "Cursor hooks",
119            "Gemini",
120            "Gemini hooks",
121            "Copilot CLI",
122            "Antigravity",
123            "Goose",
124            "Crush",
125            "Roo Code",
126            "Warp",
127            "Windsurf hooks",
128        ]
129        .into_iter()
130        .collect();
131        assert_eq!(names, expected);
132    }
133
134    #[test]
135    fn client_matrix_collapses_raw_surfaces_to_eleven_clients() {
136        let clients = collect_client_statuses_from_agents(&[
137            TargetStatus {
138                name: "Claude Code",
139                detected: true,
140                state: InstallState::Installed,
141                detail: None,
142            },
143            TargetStatus {
144                name: "Claude Code hooks",
145                detected: true,
146                state: InstallState::Installed,
147                detail: None,
148            },
149            TargetStatus {
150                name: "Cursor",
151                detected: true,
152                state: InstallState::Installed,
153                detail: None,
154            },
155            TargetStatus {
156                name: "Cursor hooks",
157                detected: true,
158                state: InstallState::NotInstalled,
159                detail: None,
160            },
161        ]);
162        assert_eq!(clients.len(), 11);
163        let claude = clients
164            .iter()
165            .find(|client| client.name == "Claude Code")
166            .expect("claude client");
167        assert_eq!(claude.state, InstallState::Installed);
168        let cursor = clients
169            .iter()
170            .find(|client| client.name == "Cursor")
171            .expect("cursor client");
172        assert_eq!(cursor.state, InstallState::Conflict);
173    }
174
175    #[test]
176    fn client_detail_ignores_undetected_optional_surfaces() {
177        let clients = collect_client_statuses_from_agents(&[
178            TargetStatus {
179                name: "Cursor",
180                detected: true,
181                state: InstallState::Installed,
182                detail: Some("~/.cursor/mcp.json".to_owned()),
183            },
184            TargetStatus {
185                name: "Cursor hooks",
186                detected: false,
187                state: InstallState::NotInstalled,
188                detail: Some("./.cursor/hooks.json not found".to_owned()),
189            },
190        ]);
191        let cursor = clients
192            .iter()
193            .find(|client| client.name == "Cursor")
194            .expect("cursor client");
195
196        assert_eq!(cursor.state, InstallState::Installed);
197        assert_eq!(
198            cursor.detail.as_deref(),
199            Some("1/1 detected surface(s) installed")
200        );
201    }
202
203    #[test]
204    fn canonical_target_key_normalizes_display_and_cli_names() {
205        assert_eq!(canonical_target_key("Claude Code"), "claude");
206        assert_eq!(canonical_target_key("Claude Code hooks"), "claude hooks");
207        assert_eq!(canonical_target_key("claude"), "claude");
208        assert_eq!(canonical_target_key("Codex"), "codex");
209        assert_eq!(canonical_target_key("codex"), "codex");
210        assert_eq!(canonical_target_key("Gemini hooks"), "gemini hooks");
211    }
212
213    #[test]
214    fn dry_run_outcome_verbs_describe_plan_not_execution() {
215        assert_eq!(
216            install_outcome_verb(&Status::Installed, true, false),
217            "would install"
218        );
219        assert_eq!(
220            install_outcome_verb(&Status::Updated, true, false),
221            "would update"
222        );
223        assert_eq!(
224            install_outcome_verb(&Status::Installed, true, true),
225            "already installed"
226        );
227        assert_eq!(
228            install_outcome_verb(&Status::Updated, true, true),
229            "already installed"
230        );
231        assert_eq!(
232            install_outcome_verb(
233                &Status::Skipped("DiffLore plugin already installed".to_owned()),
234                true,
235                true
236            ),
237            "already installed"
238        );
239        assert_eq!(
240            install_outcome_verb(&Status::Installed, false, false),
241            "installed"
242        );
243        assert_eq!(
244            install_outcome_verb(&Status::Updated, false, false),
245            "updated"
246        );
247    }
248
249    #[test]
250    fn dry_run_already_installed_uses_canonical_surface_names() {
251        let installed_surfaces = BTreeSet::from([canonical_target_key("Claude Code hooks")]);
252        let outcome = TargetOutcome {
253            name: "Claude Code hooks",
254            status: Status::Updated,
255            detail: "~/.claude/settings.json".to_owned(),
256        };
257
258        assert!(outcome_already_installed(&outcome, &installed_surfaces));
259    }
260
261    #[test]
262    fn outcome_client_names_collapses_hook_surfaces_to_restart_clients() {
263        let outcomes = vec![
264            TargetOutcome {
265                name: "Cursor",
266                status: Status::Installed,
267                detail: "~/.cursor/mcp.json".to_owned(),
268            },
269            TargetOutcome {
270                name: "Cursor hooks",
271                status: Status::Updated,
272                detail: "./.cursor/hooks.json".to_owned(),
273            },
274            TargetOutcome {
275                name: "Gemini hooks",
276                status: Status::Skipped("not found".to_owned()),
277                detail: String::new(),
278            },
279        ];
280
281        assert_eq!(outcome_client_names(&outcomes), vec!["Cursor".to_owned()]);
282    }
283
284    #[test]
285    fn canonical_record_is_skipped_on_partial_install_failure() {
286        let installed = vec!["Claude Code"];
287        let failed = vec!["Cursor"];
288        assert!(!should_write_canonical_record(false, &installed, &failed));
289        assert!(should_write_canonical_record(false, &installed, &[]));
290        assert!(!should_write_canonical_record(true, &installed, &[]));
291        assert!(!should_write_canonical_record(false, &[], &[]));
292    }
293
294    #[test]
295    fn json_probe_requires_command_and_mcp_server_arg() {
296        let (_tmp, path) = test_util::tmp_named_path("mcp.json");
297        fs::write(
298            &path,
299            r#"{ "mcpServers": { "difflore": { "command": "/tmp/fake/difflore", "args": [] } } }"#,
300        )
301        .expect("write config");
302
303        let status =
304            common::probe_json_install("Cursor", &path, "mcpServers", "/tmp/fake/difflore");
305        assert_eq!(status.state, InstallState::Conflict);
306        assert!(
307            status
308                .detail
309                .as_deref()
310                .is_some_and(|detail| detail.contains("args=[]"))
311        );
312
313        fs::write(
314            &path,
315            r#"{ "mcpServers": { "difflore": { "command": "/tmp/fake/difflore", "args": ["mcp-server"] } } }"#,
316        )
317        .expect("write config");
318        let status =
319            common::probe_json_install("Cursor", &path, "mcpServers", "/tmp/fake/difflore");
320        assert_eq!(status.state, InstallState::Installed);
321    }
322
323    #[test]
324    fn failed_outcome_names_only_counts_real_errors() {
325        let outcomes = vec![
326            TargetOutcome {
327                name: "Claude Code",
328                status: Status::Installed,
329                detail: String::new(),
330            },
331            TargetOutcome {
332                name: "Cursor",
333                status: Status::Skipped("not detected".to_owned()),
334                detail: String::new(),
335            },
336            TargetOutcome {
337                name: "Gemini",
338                status: Status::Error("write failed".to_owned()),
339                detail: String::new(),
340            },
341        ];
342
343        assert_eq!(failed_outcome_names(&outcomes), vec!["Gemini"]);
344    }
345
346    #[test]
347    fn runtime_probe_output_accepts_initialize_and_tools_list() {
348        let stdout = concat!(
349            r#"{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05"}}"#,
350            "\n",
351            r#"{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"search_rules"},{"name":"get_rules"}]}}"#,
352            "\n",
353            r#"{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\"results\":[{\"id\":\"rule-1\",\"title\":\"Probe rule title\"}]}"}],"_meta":{"impact":{"kind":"rules_index","rulesInjected":1,"rulesIndexed":12}}}}"#,
354            "\n"
355        );
356        let probe = common::evaluate_runtime_probe_output(stdout, "", true);
357        assert_eq!(probe.state, RuntimeProbeState::Ok);
358        assert!(probe.initialized);
359        assert!(probe.tools_listed);
360        assert!(probe.tool_call_completed);
361        assert_eq!(probe.tool_call_name.as_deref(), Some("search_rules"));
362        assert_eq!(probe.tool_call_rules_injected, Some(1));
363        assert_eq!(probe.tool_call_rules_indexed, Some(12));
364        assert_eq!(
365            probe.tool_call_top_result.as_deref(),
366            Some("Probe rule title")
367        );
368        assert_eq!(probe.tool_count, Some(2));
369        assert_eq!(
370            probe.tool_names,
371            vec!["search_rules".to_owned(), "get_rules".to_owned()]
372        );
373        // The human-facing detail line summarizes a clean handshake without
374        // leaking internal probe wiring (the search_rules tool call itself is
375        // asserted via the structured `tool_call_name` field above).
376        assert!(
377            probe.detail.contains("MCP handshake and tool listing OK"),
378            "{}",
379            probe.detail
380        );
381    }
382
383    #[test]
384    fn runtime_probe_input_scopes_search_to_changed_file() {
385        let input = common::build_runtime_probe_input(Some("crates/app/src/lib.rs".to_owned()));
386        let messages = input
387            .lines()
388            .map(|line| serde_json::from_str::<serde_json::Value>(line).expect("valid json"))
389            .collect::<Vec<_>>();
390
391        assert_eq!(messages.len(), 3);
392        assert_eq!(messages[2]["method"], "tools/call");
393        assert_eq!(messages[2]["params"]["name"], "search_rules");
394        assert_eq!(
395            messages[2]["params"]["arguments"]["file"],
396            "crates/app/src/lib.rs"
397        );
398        assert!(
399            messages[2]["params"]["arguments"]["intent"]
400                .as_str()
401                .expect("intent")
402                .contains("crates/app/src/lib.rs")
403        );
404        assert_eq!(
405            messages[2]["params"]["arguments"]["session_id"],
406            "difflore-mcp-status"
407        );
408    }
409
410    #[test]
411    fn runtime_probe_input_omits_file_when_no_diff_exists() {
412        let input = common::build_runtime_probe_input(None);
413        let messages = input
414            .lines()
415            .map(|line| serde_json::from_str::<serde_json::Value>(line).expect("valid json"))
416            .collect::<Vec<_>>();
417
418        assert_eq!(messages.len(), 3);
419        assert!(messages[2]["params"]["arguments"].get("file").is_none());
420        assert_eq!(
421            messages[2]["params"]["arguments"]["intent"],
422            "verify DiffLore MCP can recall review memory"
423        );
424    }
425
426    #[test]
427    fn runtime_probe_output_reports_missing_tool_list() {
428        let stdout = r#"{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05"}}"#;
429        let probe = common::evaluate_runtime_probe_output(stdout, "boom", false);
430        assert_eq!(probe.state, RuntimeProbeState::Failed);
431        assert!(probe.initialized);
432        assert!(!probe.tools_listed);
433        assert!(probe.detail.contains("stderr: boom"));
434    }
435
436    #[test]
437    fn runtime_probe_output_requires_search_rules_tool_call() {
438        let stdout = concat!(
439            r#"{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05"}}"#,
440            "\n",
441            r#"{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"search_rules"},{"name":"get_rules"}]}}"#,
442            "\n"
443        );
444        let probe = common::evaluate_runtime_probe_output(stdout, "", true);
445        assert_eq!(probe.state, RuntimeProbeState::Failed);
446        assert!(probe.initialized);
447        assert!(probe.tools_listed);
448        assert!(!probe.tool_call_completed);
449        assert!(probe.detail.contains("did not complete search_rules"));
450    }
451
452    fn diagnosis_fixture(
453        runtime_state: RuntimeProbeState,
454        record_state: CanonicalRecordState,
455    ) -> McpStatusSnapshot {
456        let (recorded_targets, actual_targets) =
457            if matches!(record_state, CanonicalRecordState::Stale) {
458                (
459                    vec!["Claude Code".to_owned()],
460                    vec!["Claude Code".to_owned(), "Claude Code hooks".to_owned()],
461                )
462            } else {
463                (
464                    vec!["Claude Code".to_owned()],
465                    vec!["Claude Code".to_owned()],
466                )
467            };
468        McpStatusSnapshot {
469            binary: "difflore".to_owned(),
470            canonical_record: CanonicalRecordStatus {
471                path: Some("mcp.json".to_owned()),
472                state: record_state,
473                detail: None,
474                recorded_targets,
475                actual_targets,
476            },
477            runtime_probe: Some(McpRuntimeProbe {
478                state: runtime_state,
479                detail: "probe detail".to_owned(),
480                initialized: matches!(runtime_state, RuntimeProbeState::Ok),
481                tools_listed: matches!(runtime_state, RuntimeProbeState::Ok),
482                tool_call_completed: matches!(runtime_state, RuntimeProbeState::Ok),
483                tool_call_name: matches!(runtime_state, RuntimeProbeState::Ok)
484                    .then(|| "search_rules".to_owned()),
485                tool_call_rules_injected: None,
486                tool_call_rules_indexed: None,
487                tool_call_top_result: None,
488                tool_count: Some(7),
489                tool_names: Vec::new(),
490            }),
491            diagnosis: None,
492            clients: vec![McpClientStatus {
493                name: "Claude Code",
494                detected: true,
495                state: InstallState::Installed,
496                detail: None,
497                surfaces: Vec::new(),
498            }],
499            agents: Vec::new(),
500        }
501    }
502
503    #[test]
504    fn diagnosis_distinguishes_healthy_runtime_from_install_record_drift() {
505        let snapshot = diagnosis_fixture(RuntimeProbeState::Ok, CanonicalRecordState::Stale);
506        let diagnosis = diagnose_status_snapshot(&snapshot);
507        assert!(diagnosis.summary.contains("server is healthy"));
508        assert!(diagnosis.summary.contains("client-wiring drift"));
509        assert!(diagnosis.next_step.contains("difflore agents install"));
510        assert_eq!(diagnosis.affected_clients, vec!["Claude Code".to_owned()]);
511        assert!(
512            diagnosis
513                .actions
514                .iter()
515                .any(|action| action.contains("Restart/reload affected client(s): Claude Code"))
516        );
517        assert!(
518            diagnosis
519                .actions
520                .iter()
521                .any(|action| action.contains("Claude Code: restart Claude Code"))
522        );
523    }
524
525    #[test]
526    fn diagnosis_for_clean_runtime_lists_installed_client_reload_steps() {
527        let snapshot = diagnosis_fixture(RuntimeProbeState::Ok, CanonicalRecordState::Present);
528        let diagnosis = diagnose_status_snapshot(&snapshot);
529        assert!(diagnosis.next_step.contains("Transport closed"));
530        assert!(
531            diagnosis
532                .actions
533                .iter()
534                .any(|action| action.contains("Claude Code: restart Claude Code"))
535        );
536        assert!(
537            diagnosis
538                .actions
539                .iter()
540                .any(|action| action.contains("completes a search_rules"))
541        );
542    }
543
544    #[test]
545    fn install_repair_targets_include_canonical_hook_drift() {
546        let snapshot = diagnosis_fixture(RuntimeProbeState::Ok, CanonicalRecordState::Stale);
547        assert_eq!(
548            install_repair_targets_for_snapshot(&snapshot),
549            vec!["Claude Code".to_owned()]
550        );
551    }
552
553    #[test]
554    fn install_repair_targets_are_empty_for_clean_installed_client() {
555        let snapshot = diagnosis_fixture(RuntimeProbeState::Ok, CanonicalRecordState::Present);
556        assert!(install_repair_targets_for_snapshot(&snapshot).is_empty());
557    }
558
559    #[test]
560    fn diagnosis_flags_runtime_failure_as_memory_server_problem() {
561        let snapshot = diagnosis_fixture(RuntimeProbeState::Failed, CanonicalRecordState::Present);
562        let diagnosis = diagnose_status_snapshot(&snapshot);
563        assert!(diagnosis.summary.contains("failed the stdio self-check"));
564        assert!(diagnosis.next_step.contains("stderr/details"));
565        assert!(
566            diagnosis
567                .actions
568                .iter()
569                .any(|action| action.contains("Rebuild or upgrade"))
570        );
571    }
572}