use std::path::Path;
use anyhow::{Context, Result};
use crate::cli::args::{
ClientValue, McpDoctorArgs, McpInstallArgs, McpReportFormat, McpUninstallArgs, McpUpdateArgs,
};
use crate::installers::{
ClientId, DiagnosticReport, DiagnosticStatus, InstallContext, InstallReport, InstallStatus,
UninstallReport, UninstallStatus, UpdateReport, UpdateStatus, installer_for,
};
pub fn execute_install(args: McpInstallArgs) -> Result<()> {
let installer = installer_for(client_id(args.client));
let ctx = InstallContext {
binary_path: args
.binary_path
.map(absolutize)
.transpose()
.context("normalizing --binary-path")?,
config_path: absolutize(args.config).context("normalizing --config")?,
dry_run: args.dry_run,
force: args.force,
};
let report = installer.install(&ctx)?;
match args.format {
McpReportFormat::Text => print!("{}", render_install_text(&report)),
McpReportFormat::Json => {
println!("{}", serde_json::to_string_pretty(&report)?);
}
}
if matches!(report.status, InstallStatus::Conflict) {
anyhow::bail!(
"install conflict for client {}: existing entry differs (re-run with --force)",
report.client
);
}
Ok(())
}
pub fn execute_update(args: McpUpdateArgs) -> Result<()> {
let installer = installer_for(client_id(args.client));
let ctx = InstallContext {
binary_path: args
.binary_path
.map(absolutize)
.transpose()
.context("normalizing --binary-path")?,
config_path: absolutize(args.config).context("normalizing --config")?,
dry_run: args.dry_run,
force: false,
};
let report = installer.update(&ctx)?;
match args.format {
McpReportFormat::Text => print!("{}", render_update_text(&report)),
McpReportFormat::Json => {
println!("{}", serde_json::to_string_pretty(&report)?);
}
}
Ok(())
}
pub fn execute_uninstall(args: McpUninstallArgs) -> Result<()> {
let installer = installer_for(client_id(args.client));
let ctx = InstallContext {
binary_path: None,
config_path: std::env::current_dir().unwrap_or_default(),
dry_run: args.dry_run,
force: false,
};
let report = installer.uninstall(&ctx)?;
match args.format {
McpReportFormat::Text => print!("{}", render_uninstall_text(&report)),
McpReportFormat::Json => {
println!("{}", serde_json::to_string_pretty(&report)?);
}
}
Ok(())
}
pub fn execute_doctor(args: McpDoctorArgs) -> Result<()> {
let installer = installer_for(client_id(args.client));
let ctx = InstallContext {
binary_path: args
.binary_path
.map(absolutize)
.transpose()
.context("normalizing --binary-path")?,
config_path: absolutize(args.config).context("normalizing --config")?,
dry_run: false,
force: false,
};
let report = installer.diagnose(&ctx)?;
match args.format {
McpReportFormat::Text => print!("{}", render_doctor_text(&report)),
McpReportFormat::Json => {
println!("{}", serde_json::to_string_pretty(&report)?);
}
}
if has_failure(&report) {
anyhow::bail!("doctor reported FAIL checks for client {}", report.client);
}
Ok(())
}
fn client_id(value: ClientValue) -> ClientId {
match value {
ClientValue::Claude => ClientId::Claude,
ClientValue::Codex => ClientId::Codex,
ClientValue::Cursor => ClientId::Cursor,
ClientValue::Opencode => ClientId::OpenCode,
}
}
fn absolutize(p: std::path::PathBuf) -> Result<std::path::PathBuf> {
if p.is_absolute() {
return Ok(p);
}
let cwd = std::env::current_dir().context("current_dir() failed")?;
Ok(cwd.join(p))
}
fn render_install_text(report: &InstallReport) -> String {
let mut out = String::new();
out.push_str(&format!("[install] client={}\n", report.client));
out.push_str(&format!(
" status: {}\n",
install_status_label(&report.status)
));
out.push_str(&format!(" binary: {}\n", report.binary_path.display()));
out.push_str(&format!(" config: {}\n", report.config_path.display()));
if !report.planned_writes.is_empty() {
out.push_str(" writes:\n");
for p in &report.planned_writes {
out.push_str(&format!(" - {}\n", p.display()));
}
}
if !report.backups.is_empty() {
out.push_str(" backups:\n");
for p in &report.backups {
out.push_str(&format!(" - {}\n", p.display()));
}
}
if !report.notes.is_empty() {
out.push_str(" notes:\n");
for n in &report.notes {
out.push_str(&format!(" - {}\n", n));
}
}
out
}
fn render_update_text(report: &UpdateReport) -> String {
let mut out = String::new();
out.push_str(&format!("[update] client={}\n", report.client));
out.push_str(&format!(
" status: {}\n",
update_status_label(&report.status)
));
if !report.updated_paths.is_empty() {
out.push_str(" updated:\n");
for p in &report.updated_paths {
out.push_str(&format!(" - {}\n", p.display()));
}
}
if !report.notes.is_empty() {
out.push_str(" notes:\n");
for n in &report.notes {
out.push_str(&format!(" - {}\n", n));
}
}
out
}
fn render_uninstall_text(report: &UninstallReport) -> String {
let mut out = String::new();
out.push_str(&format!("[uninstall] client={}\n", report.client));
out.push_str(&format!(
" status: {}\n",
uninstall_status_label(&report.status)
));
if !report.removed_paths.is_empty() {
out.push_str(" removed:\n");
for p in &report.removed_paths {
out.push_str(&format!(" - {}\n", p.display()));
}
}
if !report.backups.is_empty() {
out.push_str(" backups:\n");
for p in &report.backups {
out.push_str(&format!(" - {}\n", p.display()));
}
}
if !report.notes.is_empty() {
out.push_str(" notes:\n");
for n in &report.notes {
out.push_str(&format!(" - {}\n", n));
}
}
out
}
fn render_doctor_text(report: &DiagnosticReport) -> String {
let mut out = String::new();
out.push_str(&format!("[doctor] client={}\n", report.client));
for c in &report.checks {
out.push_str(&format!(
" [{}] {} — {}\n",
diag_label(&c.status),
c.name,
c.detail
));
}
out
}
fn install_status_label(status: &InstallStatus) -> &'static str {
match status {
InstallStatus::Installed => "installed",
InstallStatus::Unchanged => "unchanged",
InstallStatus::Conflict => "conflict",
InstallStatus::DryRun => "dry-run",
}
}
fn uninstall_status_label(status: &UninstallStatus) -> &'static str {
match status {
UninstallStatus::Removed => "removed",
UninstallStatus::NotInstalled => "not-installed",
UninstallStatus::DryRun => "dry-run",
}
}
fn update_status_label(status: &UpdateStatus) -> &'static str {
match status {
UpdateStatus::Updated => "updated",
UpdateStatus::Unchanged => "unchanged",
UpdateStatus::NotInstalled => "not-installed",
UpdateStatus::DryRun => "dry-run",
}
}
fn diag_label(status: &DiagnosticStatus) -> &'static str {
match status {
DiagnosticStatus::Ok => " ok ",
DiagnosticStatus::Warn => "warn",
DiagnosticStatus::Fail => "FAIL",
DiagnosticStatus::NotApplicable => "n/a ",
}
}
fn has_failure(report: &DiagnosticReport) -> bool {
report
.checks
.iter()
.any(|c| matches!(c.status, DiagnosticStatus::Fail))
}
#[allow(dead_code)]
fn _path_anchor(_p: &Path) {}
#[cfg(test)]
mod tests {
use super::*;
use crate::installers::{
DiagnosticCheck, DiagnosticReport, DiagnosticStatus, InstallReport, InstallStatus,
UninstallReport, UninstallStatus,
};
fn fake_install_report() -> InstallReport {
InstallReport {
client: "claude".into(),
binary_path: "/abs/spool-mcp".into(),
config_path: "/abs/spool.toml".into(),
status: InstallStatus::Installed,
planned_writes: vec!["/abs/.claude.json".into()],
backups: vec!["/abs/.claude.json.bak-spool-1".into()],
notes: vec!["binary missing".into()],
}
}
#[test]
fn render_install_text_includes_all_sections() {
let text = render_install_text(&fake_install_report());
assert!(text.contains("[install] client=claude"));
assert!(text.contains("status: installed"));
assert!(text.contains("binary: /abs/spool-mcp"));
assert!(text.contains("writes:"));
assert!(text.contains("backups:"));
assert!(text.contains("notes:"));
}
#[test]
fn render_uninstall_text_handles_empty_sections() {
let report = UninstallReport {
client: "claude".into(),
status: UninstallStatus::NotInstalled,
removed_paths: vec![],
backups: vec![],
notes: vec![],
};
let text = render_uninstall_text(&report);
assert!(text.contains("[uninstall]"));
assert!(text.contains("status: not-installed"));
assert!(!text.contains("removed:"));
assert!(!text.contains("backups:"));
}
#[test]
fn render_doctor_text_lists_checks() {
let report = DiagnosticReport {
client: "claude".into(),
checks: vec![
DiagnosticCheck {
name: "claude_config_exists".into(),
status: DiagnosticStatus::Ok,
detail: "/abs/.claude.json".into(),
},
DiagnosticCheck {
name: "spool_mcp_binary".into(),
status: DiagnosticStatus::Fail,
detail: "/abs/spool-mcp".into(),
},
],
};
let text = render_doctor_text(&report);
assert!(text.contains("[ ok ]"));
assert!(text.contains("[FAIL]"));
assert!(text.contains("claude_config_exists"));
assert!(text.contains("spool_mcp_binary"));
}
#[test]
fn has_failure_detects_any_fail() {
let mut report = DiagnosticReport {
client: "claude".into(),
checks: vec![DiagnosticCheck {
name: "x".into(),
status: DiagnosticStatus::Ok,
detail: "".into(),
}],
};
assert!(!has_failure(&report));
report.checks.push(DiagnosticCheck {
name: "y".into(),
status: DiagnosticStatus::Fail,
detail: "".into(),
});
assert!(has_failure(&report));
}
#[test]
fn absolutize_keeps_absolute_unchanged() {
let path = if cfg!(windows) {
std::path::PathBuf::from("C:\\abs\\path")
} else {
std::path::PathBuf::from("/abs/path")
};
let abs = absolutize(path.clone()).unwrap();
assert_eq!(abs, path);
}
#[test]
fn absolutize_resolves_relative_against_cwd() {
let cwd = std::env::current_dir().unwrap();
let abs = absolutize(std::path::PathBuf::from("foo")).unwrap();
assert_eq!(abs, cwd.join("foo"));
}
#[test]
fn render_update_text_includes_all_sections() {
let report = UpdateReport {
client: "claude".into(),
status: UpdateStatus::Updated,
updated_paths: vec!["/abs/.claude/hooks/spool-Stop.sh".into()],
notes: vec!["1 file(s) updated to latest templates.".into()],
};
let text = render_update_text(&report);
assert!(text.contains("[update] client=claude"));
assert!(text.contains("status: updated"));
assert!(text.contains("updated:"));
assert!(text.contains("spool-Stop.sh"));
assert!(text.contains("notes:"));
}
#[test]
fn render_update_text_handles_empty_sections() {
let report = UpdateReport {
client: "claude".into(),
status: UpdateStatus::Unchanged,
updated_paths: vec![],
notes: vec![],
};
let text = render_update_text(&report);
assert!(text.contains("[update]"));
assert!(text.contains("status: unchanged"));
assert!(!text.contains("updated:"));
assert!(!text.contains("notes:"));
}
}