1use std::path::Path;
8
9use anyhow::{Context, Result};
10
11use crate::cli::args::{
12 ClientValue, McpDoctorArgs, McpInstallArgs, McpReportFormat, McpUninstallArgs, McpUpdateArgs,
13};
14use crate::installers::{
15 ClientId, DiagnosticReport, DiagnosticStatus, InstallContext, InstallReport, InstallStatus,
16 UninstallReport, UninstallStatus, UpdateReport, UpdateStatus, installer_for,
17};
18
19pub fn execute_install(args: McpInstallArgs) -> Result<()> {
20 let installer = installer_for(client_id(args.client));
21 let ctx = InstallContext {
22 binary_path: args
23 .binary_path
24 .map(absolutize)
25 .transpose()
26 .context("normalizing --binary-path")?,
27 config_path: absolutize(args.config).context("normalizing --config")?,
28 dry_run: args.dry_run,
29 force: args.force,
30 };
31
32 let report = installer.install(&ctx)?;
33 match args.format {
34 McpReportFormat::Text => print!("{}", render_install_text(&report)),
35 McpReportFormat::Json => {
36 println!("{}", serde_json::to_string_pretty(&report)?);
37 }
38 }
39 if matches!(report.status, InstallStatus::Conflict) {
40 anyhow::bail!(
41 "install conflict for client {}: existing entry differs (re-run with --force)",
42 report.client
43 );
44 }
45 Ok(())
46}
47
48pub fn execute_update(args: McpUpdateArgs) -> Result<()> {
49 let installer = installer_for(client_id(args.client));
50 let ctx = InstallContext {
51 binary_path: args
52 .binary_path
53 .map(absolutize)
54 .transpose()
55 .context("normalizing --binary-path")?,
56 config_path: absolutize(args.config).context("normalizing --config")?,
57 dry_run: args.dry_run,
58 force: false,
59 };
60
61 let report = installer.update(&ctx)?;
62 match args.format {
63 McpReportFormat::Text => print!("{}", render_update_text(&report)),
64 McpReportFormat::Json => {
65 println!("{}", serde_json::to_string_pretty(&report)?);
66 }
67 }
68 Ok(())
69}
70
71pub fn execute_uninstall(args: McpUninstallArgs) -> Result<()> {
72 let installer = installer_for(client_id(args.client));
73 let ctx = InstallContext {
74 binary_path: None,
75 config_path: std::env::current_dir().unwrap_or_default(),
76 dry_run: args.dry_run,
77 force: false,
78 };
79
80 let report = installer.uninstall(&ctx)?;
81 match args.format {
82 McpReportFormat::Text => print!("{}", render_uninstall_text(&report)),
83 McpReportFormat::Json => {
84 println!("{}", serde_json::to_string_pretty(&report)?);
85 }
86 }
87 Ok(())
88}
89
90pub fn execute_doctor(args: McpDoctorArgs) -> Result<()> {
91 let installer = installer_for(client_id(args.client));
92 let ctx = InstallContext {
93 binary_path: args
94 .binary_path
95 .map(absolutize)
96 .transpose()
97 .context("normalizing --binary-path")?,
98 config_path: absolutize(args.config).context("normalizing --config")?,
99 dry_run: false,
100 force: false,
101 };
102
103 let report = installer.diagnose(&ctx)?;
104 match args.format {
105 McpReportFormat::Text => print!("{}", render_doctor_text(&report)),
106 McpReportFormat::Json => {
107 println!("{}", serde_json::to_string_pretty(&report)?);
108 }
109 }
110 if has_failure(&report) {
111 anyhow::bail!("doctor reported FAIL checks for client {}", report.client);
112 }
113 Ok(())
114}
115
116fn client_id(value: ClientValue) -> ClientId {
117 match value {
118 ClientValue::Claude => ClientId::Claude,
119 ClientValue::Codex => ClientId::Codex,
120 ClientValue::Cursor => ClientId::Cursor,
121 ClientValue::Opencode => ClientId::OpenCode,
122 }
123}
124
125fn absolutize(p: std::path::PathBuf) -> Result<std::path::PathBuf> {
126 if p.is_absolute() {
127 return Ok(p);
128 }
129 let cwd = std::env::current_dir().context("current_dir() failed")?;
130 Ok(cwd.join(p))
131}
132
133fn render_install_text(report: &InstallReport) -> String {
134 let mut out = String::new();
135 out.push_str(&format!("[install] client={}\n", report.client));
136 out.push_str(&format!(
137 " status: {}\n",
138 install_status_label(&report.status)
139 ));
140 out.push_str(&format!(" binary: {}\n", report.binary_path.display()));
141 out.push_str(&format!(" config: {}\n", report.config_path.display()));
142 if !report.planned_writes.is_empty() {
143 out.push_str(" writes:\n");
144 for p in &report.planned_writes {
145 out.push_str(&format!(" - {}\n", p.display()));
146 }
147 }
148 if !report.backups.is_empty() {
149 out.push_str(" backups:\n");
150 for p in &report.backups {
151 out.push_str(&format!(" - {}\n", p.display()));
152 }
153 }
154 if !report.notes.is_empty() {
155 out.push_str(" notes:\n");
156 for n in &report.notes {
157 out.push_str(&format!(" - {}\n", n));
158 }
159 }
160 out
161}
162
163fn render_update_text(report: &UpdateReport) -> String {
164 let mut out = String::new();
165 out.push_str(&format!("[update] client={}\n", report.client));
166 out.push_str(&format!(
167 " status: {}\n",
168 update_status_label(&report.status)
169 ));
170 if !report.updated_paths.is_empty() {
171 out.push_str(" updated:\n");
172 for p in &report.updated_paths {
173 out.push_str(&format!(" - {}\n", p.display()));
174 }
175 }
176 if !report.notes.is_empty() {
177 out.push_str(" notes:\n");
178 for n in &report.notes {
179 out.push_str(&format!(" - {}\n", n));
180 }
181 }
182 out
183}
184
185fn render_uninstall_text(report: &UninstallReport) -> String {
186 let mut out = String::new();
187 out.push_str(&format!("[uninstall] client={}\n", report.client));
188 out.push_str(&format!(
189 " status: {}\n",
190 uninstall_status_label(&report.status)
191 ));
192 if !report.removed_paths.is_empty() {
193 out.push_str(" removed:\n");
194 for p in &report.removed_paths {
195 out.push_str(&format!(" - {}\n", p.display()));
196 }
197 }
198 if !report.backups.is_empty() {
199 out.push_str(" backups:\n");
200 for p in &report.backups {
201 out.push_str(&format!(" - {}\n", p.display()));
202 }
203 }
204 if !report.notes.is_empty() {
205 out.push_str(" notes:\n");
206 for n in &report.notes {
207 out.push_str(&format!(" - {}\n", n));
208 }
209 }
210 out
211}
212
213fn render_doctor_text(report: &DiagnosticReport) -> String {
214 let mut out = String::new();
215 out.push_str(&format!("[doctor] client={}\n", report.client));
216 for c in &report.checks {
217 out.push_str(&format!(
218 " [{}] {} — {}\n",
219 diag_label(&c.status),
220 c.name,
221 c.detail
222 ));
223 }
224 out
225}
226
227fn install_status_label(status: &InstallStatus) -> &'static str {
228 match status {
229 InstallStatus::Installed => "installed",
230 InstallStatus::Unchanged => "unchanged",
231 InstallStatus::Conflict => "conflict",
232 InstallStatus::DryRun => "dry-run",
233 }
234}
235
236fn uninstall_status_label(status: &UninstallStatus) -> &'static str {
237 match status {
238 UninstallStatus::Removed => "removed",
239 UninstallStatus::NotInstalled => "not-installed",
240 UninstallStatus::DryRun => "dry-run",
241 }
242}
243
244fn update_status_label(status: &UpdateStatus) -> &'static str {
245 match status {
246 UpdateStatus::Updated => "updated",
247 UpdateStatus::Unchanged => "unchanged",
248 UpdateStatus::NotInstalled => "not-installed",
249 UpdateStatus::DryRun => "dry-run",
250 }
251}
252
253fn diag_label(status: &DiagnosticStatus) -> &'static str {
254 match status {
255 DiagnosticStatus::Ok => " ok ",
256 DiagnosticStatus::Warn => "warn",
257 DiagnosticStatus::Fail => "FAIL",
258 DiagnosticStatus::NotApplicable => "n/a ",
259 }
260}
261
262fn has_failure(report: &DiagnosticReport) -> bool {
263 report
264 .checks
265 .iter()
266 .any(|c| matches!(c.status, DiagnosticStatus::Fail))
267}
268
269#[allow(dead_code)]
272fn _path_anchor(_p: &Path) {}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::installers::{
278 DiagnosticCheck, DiagnosticReport, DiagnosticStatus, InstallReport, InstallStatus,
279 UninstallReport, UninstallStatus,
280 };
281
282 fn fake_install_report() -> InstallReport {
283 InstallReport {
284 client: "claude".into(),
285 binary_path: "/abs/spool-mcp".into(),
286 config_path: "/abs/spool.toml".into(),
287 status: InstallStatus::Installed,
288 planned_writes: vec!["/abs/.claude.json".into()],
289 backups: vec!["/abs/.claude.json.bak-spool-1".into()],
290 notes: vec!["binary missing".into()],
291 }
292 }
293
294 #[test]
295 fn render_install_text_includes_all_sections() {
296 let text = render_install_text(&fake_install_report());
297 assert!(text.contains("[install] client=claude"));
298 assert!(text.contains("status: installed"));
299 assert!(text.contains("binary: /abs/spool-mcp"));
300 assert!(text.contains("writes:"));
301 assert!(text.contains("backups:"));
302 assert!(text.contains("notes:"));
303 }
304
305 #[test]
306 fn render_uninstall_text_handles_empty_sections() {
307 let report = UninstallReport {
308 client: "claude".into(),
309 status: UninstallStatus::NotInstalled,
310 removed_paths: vec![],
311 backups: vec![],
312 notes: vec![],
313 };
314 let text = render_uninstall_text(&report);
315 assert!(text.contains("[uninstall]"));
316 assert!(text.contains("status: not-installed"));
317 assert!(!text.contains("removed:"));
318 assert!(!text.contains("backups:"));
319 }
320
321 #[test]
322 fn render_doctor_text_lists_checks() {
323 let report = DiagnosticReport {
324 client: "claude".into(),
325 checks: vec![
326 DiagnosticCheck {
327 name: "claude_config_exists".into(),
328 status: DiagnosticStatus::Ok,
329 detail: "/abs/.claude.json".into(),
330 },
331 DiagnosticCheck {
332 name: "spool_mcp_binary".into(),
333 status: DiagnosticStatus::Fail,
334 detail: "/abs/spool-mcp".into(),
335 },
336 ],
337 };
338 let text = render_doctor_text(&report);
339 assert!(text.contains("[ ok ]"));
340 assert!(text.contains("[FAIL]"));
341 assert!(text.contains("claude_config_exists"));
342 assert!(text.contains("spool_mcp_binary"));
343 }
344
345 #[test]
346 fn has_failure_detects_any_fail() {
347 let mut report = DiagnosticReport {
348 client: "claude".into(),
349 checks: vec![DiagnosticCheck {
350 name: "x".into(),
351 status: DiagnosticStatus::Ok,
352 detail: "".into(),
353 }],
354 };
355 assert!(!has_failure(&report));
356 report.checks.push(DiagnosticCheck {
357 name: "y".into(),
358 status: DiagnosticStatus::Fail,
359 detail: "".into(),
360 });
361 assert!(has_failure(&report));
362 }
363
364 #[test]
365 fn absolutize_keeps_absolute_unchanged() {
366 let path = if cfg!(windows) {
367 std::path::PathBuf::from("C:\\abs\\path")
368 } else {
369 std::path::PathBuf::from("/abs/path")
370 };
371 let abs = absolutize(path.clone()).unwrap();
372 assert_eq!(abs, path);
373 }
374
375 #[test]
376 fn absolutize_resolves_relative_against_cwd() {
377 let cwd = std::env::current_dir().unwrap();
378 let abs = absolutize(std::path::PathBuf::from("foo")).unwrap();
379 assert_eq!(abs, cwd.join("foo"));
380 }
381
382 #[test]
383 fn render_update_text_includes_all_sections() {
384 let report = UpdateReport {
385 client: "claude".into(),
386 status: UpdateStatus::Updated,
387 updated_paths: vec!["/abs/.claude/hooks/spool-Stop.sh".into()],
388 notes: vec!["1 file(s) updated to latest templates.".into()],
389 };
390 let text = render_update_text(&report);
391 assert!(text.contains("[update] client=claude"));
392 assert!(text.contains("status: updated"));
393 assert!(text.contains("updated:"));
394 assert!(text.contains("spool-Stop.sh"));
395 assert!(text.contains("notes:"));
396 }
397
398 #[test]
399 fn render_update_text_handles_empty_sections() {
400 let report = UpdateReport {
401 client: "claude".into(),
402 status: UpdateStatus::Unchanged,
403 updated_paths: vec![],
404 notes: vec![],
405 };
406 let text = render_update_text(&report);
407 assert!(text.contains("[update]"));
408 assert!(text.contains("status: unchanged"));
409 assert!(!text.contains("updated:"));
410 assert!(!text.contains("notes:"));
411 }
412}