1mod 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 #[test]
73 fn agents_table_orders_claude_then_claude_hooks_then_codex_first() {
74 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 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 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 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 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}