difflore_cli/mcp_install/
uninstall.rs1use std::collections::BTreeSet;
11
12use colored::Colorize;
13
14use super::{
15 Status, TargetOutcome,
16 common::{canonical_target_key, delete_canonical_record, read_canonical_record_targets},
17 registry::{self, AGENTS, AgentSpec},
18};
19use crate::style::{self, sym};
20
21fn record_selects_claude(recorded_keys: &BTreeSet<String>) -> bool {
25 recorded_keys.contains("claude") || recorded_keys.contains("claude hooks")
26}
27
28fn selected_specs(recorded_keys: &BTreeSet<String>) -> Vec<&'static AgentSpec> {
36 let attempt_all = recorded_keys.is_empty();
37 AGENTS
38 .iter()
39 .filter(|spec| spec.name != "Claude Code hooks")
41 .filter(|spec| {
42 if attempt_all {
43 return true;
44 }
45 if spec.name == "Claude Code" {
46 return record_selects_claude(recorded_keys);
49 }
50 recorded_keys.contains(&canonical_target_key(spec.name))
51 })
52 .collect()
53}
54
55fn uninstall_all_targets(recorded_keys: &BTreeSet<String>, dry_run: bool) -> Vec<TargetOutcome> {
56 selected_specs(recorded_keys)
57 .into_iter()
58 .map(|spec| registry::uninstall(spec, dry_run))
59 .collect()
60}
61
62const fn uninstall_outcome_verb(status: &Status, dry_run: bool) -> &'static str {
63 match status {
64 Status::Removed | Status::Installed | Status::Updated if dry_run => "would remove",
65 Status::Removed | Status::Installed | Status::Updated => "removed",
66 Status::Skipped(_) => "nothing to remove",
67 Status::Error(_) => "error",
68 }
69}
70
71fn print_uninstall_outcomes(outcomes: &[TargetOutcome], dry_run: bool) {
72 let mut nothing_to_remove: Vec<&str> = Vec::new();
73 for o in outcomes {
74 if matches!(o.status, Status::Skipped(_)) {
75 nothing_to_remove.push(o.name);
76 continue;
77 }
78 let plain_verb = uninstall_outcome_verb(&o.status, dry_run);
79 let (mark, verb) = match &o.status {
80 Status::Error(_) => (style::err(sym::ERR), style::danger(plain_verb)),
81 _ if dry_run => (style::amber("·"), style::amber(plain_verb)),
82 _ => (style::ok(sym::OK), style::emerald(plain_verb)),
83 };
84 println!(" {mark} {:<14} {verb}", o.name.bold());
85 let sub = match &o.status {
86 Status::Error(r) => r.as_str(),
87 _ => o.detail.as_str(),
88 };
89 if !sub.is_empty() {
90 println!(" {}", style::pewter(sub));
91 }
92 }
93 if !nothing_to_remove.is_empty() {
94 println!(
95 " {} {}",
96 style::pewter("·"),
97 style::pewter(&format!(
98 "no DiffLore entry found (already clean): {}",
99 nothing_to_remove.join(", ")
100 ))
101 );
102 }
103}
104
105fn removed_outcome_names(outcomes: &[TargetOutcome]) -> Vec<&'static str> {
106 outcomes
107 .iter()
108 .filter(|o| matches!(o.status, Status::Removed))
109 .map(|o| o.name)
110 .collect()
111}
112
113fn errored_outcome_names(outcomes: &[TargetOutcome]) -> Vec<&'static str> {
114 outcomes
115 .iter()
116 .filter(|o| matches!(o.status, Status::Error(_)))
117 .map(|o| o.name)
118 .collect()
119}
120
121pub fn uninstall_all(dry_run: bool) {
124 let recorded = read_canonical_record_targets();
125 let recorded_keys: BTreeSet<String> =
126 recorded.iter().map(|t| canonical_target_key(t)).collect();
127
128 let message = if dry_run {
129 "Checking DiffLore MCP removal plan for every recorded agent"
130 } else {
131 "Removing DiffLore MCP server from every recorded agent"
132 };
133 let dry_tag = if dry_run {
134 format!(" {}", style::amber("(dry-run; no changes)"))
135 } else {
136 String::new()
137 };
138 println!(
139 "{} {}{dry_tag}",
140 style::emerald(sym::TIP),
141 style::pewter(message),
142 );
143 if recorded_keys.is_empty() {
144 println!(
145 " {} {}",
146 style::pewter("·"),
147 style::pewter(
148 "no canonical record (~/.difflore/mcp.json) — scanning every supported surface"
149 ),
150 );
151 } else {
152 println!(
153 " {} {} {}",
154 style::pewter("recorded targets:"),
155 style::emerald(&recorded.join(", ")),
156 style::pewter(&format!("({})", recorded.len())),
157 );
158 }
159 println!();
160
161 let outcomes = uninstall_all_targets(&recorded_keys, dry_run);
162 print_uninstall_outcomes(&outcomes, dry_run);
163
164 let removed = removed_outcome_names(&outcomes);
165 let errored = errored_outcome_names(&outcomes);
166
167 println!();
168 if dry_run {
169 println!(
170 "{} dry-run only: no MCP config, hooks, or the canonical record were changed.",
171 style::emerald(sym::TIP)
172 );
173 println!(
174 " {} apply with {} when the plan looks right.",
175 style::pewter(sym::BULLET),
176 style::cmd("difflore agents uninstall"),
177 );
178 return;
179 }
180
181 if errored.is_empty() {
185 match delete_canonical_record() {
186 Ok(Some(path)) => println!(
187 "{} removed canonical record {}",
188 style::ok(sym::OK),
189 style::pewter(&path.display().to_string()),
190 ),
191 Ok(None) => {}
192 Err(e) => eprintln!(
193 "{} failed to remove canonical record: {e}",
194 style::warn("warning:")
195 ),
196 }
197 } else {
198 eprintln!(
199 "{} {} failed to clean up; canonical record kept so {} can show what remains.",
200 style::warn("warning:"),
201 errored.join(", "),
202 style::cmd("difflore agents status"),
203 );
204 }
205
206 println!();
207 if removed.is_empty() && errored.is_empty() {
208 println!(
209 "{} nothing to remove — DiffLore was not wired into any detected agent.",
210 style::emerald(sym::TIP)
211 );
212 } else {
213 println!(
214 "{} restart/reload any open agents so they drop the DiffLore memory server.",
215 style::emerald(sym::TIP),
216 );
217 println!(
218 " {} re-add later with {} when you want team review memory back.",
219 style::pewter(sym::BULLET),
220 style::cmd("difflore agents install"),
221 );
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn uninstall_verbs_describe_removal_plan_and_execution() {
231 assert_eq!(
232 uninstall_outcome_verb(&Status::Removed, true),
233 "would remove"
234 );
235 assert_eq!(uninstall_outcome_verb(&Status::Removed, false), "removed");
236 assert_eq!(
237 uninstall_outcome_verb(&Status::Skipped("x".into()), false),
238 "nothing to remove"
239 );
240 assert_eq!(
241 uninstall_outcome_verb(&Status::Error("x".into()), false),
242 "error"
243 );
244 }
245
246 fn selected_names(recorded_keys: &BTreeSet<String>) -> Vec<String> {
247 selected_specs(recorded_keys)
248 .into_iter()
249 .map(|spec| canonical_target_key(spec.name))
250 .collect()
251 }
252
253 #[test]
254 fn dispatch_filters_to_recorded_targets_when_record_present() {
255 let recorded: BTreeSet<String> = [
257 canonical_target_key("Cursor"),
258 canonical_target_key("Cursor hooks"),
259 ]
260 .into_iter()
261 .collect();
262 assert_eq!(selected_names(&recorded), vec!["cursor", "cursor hooks"]);
263 }
264
265 #[test]
266 fn claude_hooks_only_record_still_selects_the_claude_remover() {
267 let recorded: BTreeSet<String> =
270 std::iter::once(canonical_target_key("Claude Code hooks")).collect();
271 assert!(selected_names(&recorded).contains(&"claude".to_owned()));
272 }
273
274 #[test]
275 fn dispatch_attempts_every_surface_when_record_empty() {
276 let empty = BTreeSet::new();
277 let expected = AGENTS
281 .iter()
282 .filter(|s| s.name != "Claude Code hooks")
283 .count();
284 assert_eq!(selected_specs(&empty).len(), expected);
285 let names = selected_names(&empty);
286 assert!(names.contains(&"claude".to_owned()));
287 assert!(names.contains(&"windsurf hooks".to_owned()));
288 }
289
290 #[test]
291 fn removed_and_errored_partitions_match_status() {
292 let outcomes = vec![
293 TargetOutcome {
294 name: "Cursor",
295 status: Status::Removed,
296 detail: String::new(),
297 },
298 TargetOutcome {
299 name: "Gemini",
300 status: Status::Skipped("none".into()),
301 detail: String::new(),
302 },
303 TargetOutcome {
304 name: "Goose",
305 status: Status::Error("boom".into()),
306 detail: String::new(),
307 },
308 ];
309 assert_eq!(removed_outcome_names(&outcomes), vec!["Cursor"]);
310 assert_eq!(errored_outcome_names(&outcomes), vec!["Goose"]);
311 }
312}