Skip to main content

mag/
setup.rs

1//! Interactive setup wizard for configuring AI coding tools to use MAG.
2//!
3//! The `mag setup` subcommand detects installed AI tools, presents their
4//! configuration status, and writes MCP config entries so that each tool
5//! can communicate with the MAG daemon.
6
7use std::io::{self, BufRead, Write};
8use std::path::Path;
9
10use anyhow::{Context, Result};
11
12use crate::config_writer::{self, ConfigWriteResult, RemoveResult, TransportMode};
13use crate::tool_detection::{self, DetectedTool, DetectionResult, MagConfigStatus};
14
15// ---------------------------------------------------------------------------
16// Public types
17// ---------------------------------------------------------------------------
18
19/// Arguments for the `mag setup` subcommand, mapped from the CLI layer.
20pub struct SetupArgs {
21    pub non_interactive: bool,
22    pub tools: Option<Vec<String>>,
23    pub transport: TransportMode,
24    pub port: u16,
25    pub no_start: bool,
26    pub uninstall: bool,
27    pub force: bool,
28}
29
30/// Summary of a configuration run.
31#[derive(Debug, Default)]
32struct ConfigureSummary {
33    written: Vec<String>,
34    already_current: Vec<String>,
35    unsupported: Vec<(String, String)>,
36    deferred: Vec<String>,
37    errors: Vec<(String, String)>,
38}
39
40// ---------------------------------------------------------------------------
41// Public entry point
42// ---------------------------------------------------------------------------
43
44/// Main entry point for `mag setup`.
45pub async fn run_setup(args: SetupArgs) -> Result<()> {
46    if args.uninstall {
47        return run_uninstall(None);
48    }
49
50    // Detect phase
51    println!("\n  Detecting AI coding tools...\n");
52    let result: DetectionResult = tokio::task::spawn_blocking(|| detect_phase(None))
53        .await
54        .context("tool detection task panicked")?;
55
56    present_detection(&result);
57
58    // Determine which tools to configure
59    let tools_to_configure = select_tools(&result, &args)?;
60
61    if tools_to_configure.is_empty() {
62        println!("  No tools to configure.");
63        return Ok(());
64    }
65
66    // Configure phase
67    let summary = configure_tools(&tools_to_configure, args.transport)?;
68    present_summary(&summary);
69
70    // Daemon phase
71    #[cfg(feature = "daemon-http")]
72    maybe_start_daemon(args.port, args.no_start)?;
73
74    Ok(())
75}
76
77// ---------------------------------------------------------------------------
78// Detection phase
79// ---------------------------------------------------------------------------
80
81fn detect_phase(project_root: Option<&Path>) -> DetectionResult {
82    tool_detection::detect_all_tools(project_root)
83}
84
85// ---------------------------------------------------------------------------
86// Presentation
87// ---------------------------------------------------------------------------
88
89fn present_detection(result: &DetectionResult) {
90    if result.detected.is_empty() {
91        println!("  No AI coding tools detected.\n");
92        return;
93    }
94
95    println!("  Detected tools:\n");
96    for dt in &result.detected {
97        let status_icon = match &dt.mag_status {
98            MagConfigStatus::Configured => "\u{2713}",    // check mark
99            MagConfigStatus::NotConfigured => "\u{2717}", // X mark
100            MagConfigStatus::Misconfigured(_) => "\u{26a0}", // warning
101            MagConfigStatus::Unreadable(_) => "\u{26a0}", // warning
102        };
103        let status_label = match &dt.mag_status {
104            MagConfigStatus::Configured => "configured",
105            MagConfigStatus::NotConfigured => "not configured",
106            MagConfigStatus::Misconfigured(reason) => reason.as_str(),
107            MagConfigStatus::Unreadable(reason) => reason.as_str(),
108        };
109        println!(
110            "    {status_icon} {name:<20} {status_label}",
111            name = dt.tool.display_name(),
112        );
113        tracing::debug!(
114            tool = %dt.tool.display_name(),
115            path = %dt.config_path.display(),
116            "detected tool"
117        );
118    }
119
120    if !result.not_found.is_empty() {
121        println!();
122        let not_found_names: Vec<&str> = result
123            .not_found
124            .iter()
125            .map(|t: &tool_detection::AiTool| t.display_name())
126            .collect();
127        tracing::debug!(tools = ?not_found_names, "tools not found");
128    }
129    println!();
130}
131
132fn present_summary(summary: &ConfigureSummary) {
133    println!("  Configuration summary:\n");
134
135    for name in &summary.written {
136        println!("    \u{2713} {name} — configured");
137    }
138    for name in &summary.already_current {
139        println!("    \u{2713} {name} — already current");
140    }
141    for name in &summary.deferred {
142        println!("    - {name} — deferred (format not yet supported)");
143    }
144    for (name, reason) in &summary.unsupported {
145        println!("    - {name} — skipped ({reason})");
146    }
147    for (name, err) in &summary.errors {
148        println!("    \u{2717} {name} — error: {err}");
149    }
150    println!();
151}
152
153// ---------------------------------------------------------------------------
154// Tool selection
155// ---------------------------------------------------------------------------
156
157fn select_tools<'a>(
158    result: &'a DetectionResult,
159    args: &SetupArgs,
160) -> Result<Vec<&'a DetectedTool>> {
161    // Filter by --tools if provided
162    let candidates: Vec<&DetectedTool> = if let Some(ref tool_names) = args.tools {
163        let lower_names: Vec<String> = tool_names.iter().map(|n| n.to_lowercase()).collect();
164        result
165            .detected
166            .iter()
167            .filter(|dt| {
168                let display_lower = dt.tool.display_name().to_lowercase();
169                let variant_lower = format!("{:?}", dt.tool).to_lowercase();
170                lower_names.iter().any(|n| {
171                    display_lower.contains(n.as_str()) || variant_lower.contains(n.as_str())
172                })
173            })
174            .collect()
175    } else {
176        result.detected.iter().collect()
177    };
178
179    // In force mode, configure all matched tools regardless of status
180    if args.force {
181        return Ok(candidates);
182    }
183
184    // Filter to only unconfigured/misconfigured tools
185    let actionable: Vec<&DetectedTool> = candidates
186        .into_iter()
187        .filter(|dt| !matches!(dt.mag_status, MagConfigStatus::Configured))
188        .collect();
189
190    if actionable.is_empty() {
191        return Ok(vec![]);
192    }
193
194    // Non-interactive: configure all actionable tools
195    if args.non_interactive || is_ci() || !is_tty() {
196        return Ok(actionable);
197    }
198
199    // Interactive: prompt user
200    select_tools_interactive(&actionable)
201}
202
203fn select_tools_interactive<'a>(tools: &[&'a DetectedTool]) -> Result<Vec<&'a DetectedTool>> {
204    println!(
205        "  Configure {} tool{}? [Y/n] ",
206        tools.len(),
207        if tools.len() == 1 { "" } else { "s" }
208    );
209    io::stdout().flush().context("flushing stdout")?;
210
211    let stdin = io::stdin();
212    let mut line = String::new();
213    stdin
214        .lock()
215        .read_line(&mut line)
216        .context("reading user input")?;
217
218    let trimmed = line.trim().to_lowercase();
219    if trimmed.is_empty() || trimmed == "y" || trimmed == "yes" {
220        Ok(tools.to_vec())
221    } else {
222        Ok(vec![])
223    }
224}
225
226// ---------------------------------------------------------------------------
227// Configuration phase
228// ---------------------------------------------------------------------------
229
230fn configure_tools(tools: &[&DetectedTool], mode: TransportMode) -> Result<ConfigureSummary> {
231    let mut summary = ConfigureSummary::default();
232
233    for tool in tools {
234        let name = tool.tool.display_name().to_string();
235        match config_writer::write_config(tool, mode) {
236            Ok(ConfigWriteResult::Written { backup_path }) => {
237                if let Some(ref bak) = backup_path {
238                    tracing::debug!(tool = %name, backup = %bak.display(), "config backed up");
239                }
240                let _ = backup_path; // suppress unused warning
241                summary.written.push(name);
242            }
243            Ok(ConfigWriteResult::AlreadyCurrent) => {
244                summary.already_current.push(name);
245            }
246            Ok(ConfigWriteResult::UnsupportedFormat { reason }) => {
247                summary.unsupported.push((name, reason));
248            }
249            Ok(ConfigWriteResult::Deferred { tool: ai_tool }) => {
250                summary.deferred.push(ai_tool.display_name().to_string());
251            }
252            Err(e) => {
253                summary.errors.push((name, format!("{e:#}")));
254            }
255        }
256    }
257
258    Ok(summary)
259}
260
261// ---------------------------------------------------------------------------
262// Uninstall
263// ---------------------------------------------------------------------------
264
265fn run_uninstall(project_root: Option<&Path>) -> Result<()> {
266    println!("\n  Removing MAG from all detected tools...\n");
267
268    let result = detect_phase(project_root);
269
270    if result.detected.is_empty() {
271        println!("  No tools detected — nothing to remove.");
272        return Ok(());
273    }
274
275    let mut removed = Vec::new();
276    let mut not_present = Vec::new();
277    let mut errors = Vec::new();
278
279    for dt in &result.detected {
280        let name = dt.tool.display_name().to_string();
281        match config_writer::remove_config(dt) {
282            Ok(RemoveResult::Removed) => removed.push(name),
283            Ok(RemoveResult::NotPresent | RemoveResult::NoConfigFile) => {
284                not_present.push(name);
285            }
286            Ok(RemoveResult::UnsupportedFormat { reason }) => {
287                not_present.push(format!("{name} (skipped: {reason})"));
288            }
289            Err(e) => errors.push((name, format!("{e:#}"))),
290        }
291    }
292
293    println!("  Uninstall summary:\n");
294    for name in &removed {
295        println!("    \u{2713} {name} — removed");
296    }
297    for name in &not_present {
298        println!("    - {name} — was not configured");
299    }
300    for (name, err) in &errors {
301        println!("    \u{2717} {name} — error: {err}");
302    }
303    println!();
304
305    Ok(())
306}
307
308// ---------------------------------------------------------------------------
309// Daemon management
310// ---------------------------------------------------------------------------
311
312#[cfg(feature = "daemon-http")]
313fn maybe_start_daemon(port: u16, no_start: bool) -> Result<()> {
314    if no_start {
315        tracing::debug!("--no-start: skipping daemon check");
316        return Ok(());
317    }
318
319    // Check if daemon is already running via daemon.json
320    match crate::daemon::DaemonInfo::read() {
321        Ok(Some(info)) if !info.is_stale() => {
322            println!(
323                "  MAG daemon already running (pid {}, port {}).\n",
324                info.pid, info.port
325            );
326            return Ok(());
327        }
328        _ => {}
329    }
330
331    println!("  Tip: start the MAG daemon with `mag serve` (port {port}).\n");
332
333    Ok(())
334}
335
336// ---------------------------------------------------------------------------
337// Helpers
338// ---------------------------------------------------------------------------
339
340/// Parses a CLI transport string into a `TransportMode`.
341pub fn parse_transport(s: &str, port: u16) -> Result<TransportMode> {
342    match s.to_lowercase().as_str() {
343        "command" | "cmd" => Ok(TransportMode::Command),
344        "http" => Ok(TransportMode::Http { port }),
345        "stdio" => Ok(TransportMode::Stdio),
346        other => {
347            anyhow::bail!("unknown transport mode: '{other}' (expected command, http, or stdio)")
348        }
349    }
350}
351
352/// Returns `true` if we detect a CI environment.
353fn is_ci() -> bool {
354    std::env::var_os("CI").is_some() || std::env::var_os("GITHUB_ACTIONS").is_some()
355}
356
357/// Returns `true` if stdin appears to be a TTY.
358fn is_tty() -> bool {
359    use std::io::IsTerminal;
360    io::stdin().is_terminal()
361}
362
363// ---------------------------------------------------------------------------
364// Tests
365// ---------------------------------------------------------------------------
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::test_helpers::with_temp_home;
371    use crate::tool_detection::{AiTool, ConfigScope, DetectedTool, MagConfigStatus};
372    use std::path::PathBuf;
373
374    // -----------------------------------------------------------------------
375    // Transport parsing
376    // -----------------------------------------------------------------------
377
378    #[test]
379    fn parse_transport_command() {
380        let mode = parse_transport("command", 4242).unwrap();
381        assert_eq!(mode, TransportMode::Command);
382    }
383
384    #[test]
385    fn parse_transport_cmd_alias() {
386        let mode = parse_transport("cmd", 4242).unwrap();
387        assert_eq!(mode, TransportMode::Command);
388    }
389
390    #[test]
391    fn parse_transport_http() {
392        let mode = parse_transport("http", 9090).unwrap();
393        assert_eq!(mode, TransportMode::Http { port: 9090 });
394    }
395
396    #[test]
397    fn parse_transport_stdio() {
398        let mode = parse_transport("stdio", 4242).unwrap();
399        assert_eq!(mode, TransportMode::Stdio);
400    }
401
402    #[test]
403    fn parse_transport_case_insensitive() {
404        let mode = parse_transport("HTTP", 8080).unwrap();
405        assert_eq!(mode, TransportMode::Http { port: 8080 });
406    }
407
408    #[test]
409    fn parse_transport_unknown_errors() {
410        let result = parse_transport("grpc", 4242);
411        assert!(result.is_err());
412        let msg = result.unwrap_err().to_string();
413        assert!(
414            msg.contains("grpc"),
415            "error should mention the bad input: {msg}"
416        );
417    }
418
419    // -----------------------------------------------------------------------
420    // SetupArgs construction
421    // -----------------------------------------------------------------------
422
423    #[test]
424    fn setup_args_defaults() {
425        let args = SetupArgs {
426            non_interactive: false,
427            tools: None,
428            transport: TransportMode::Command,
429            port: 4242,
430            no_start: false,
431            uninstall: false,
432            force: false,
433        };
434        assert!(!args.non_interactive);
435        assert!(args.tools.is_none());
436        assert_eq!(args.port, 4242);
437    }
438
439    // -----------------------------------------------------------------------
440    // Tool selection helpers
441    // -----------------------------------------------------------------------
442
443    fn make_detected(tool: AiTool, status: MagConfigStatus) -> DetectedTool {
444        DetectedTool {
445            tool,
446            config_path: PathBuf::from("/fake/config.json"),
447            scope: ConfigScope::Global,
448            mag_status: status,
449        }
450    }
451
452    #[test]
453    fn select_tools_non_interactive_configures_unconfigured() {
454        let result = DetectionResult {
455            detected: vec![
456                make_detected(AiTool::ClaudeCode, MagConfigStatus::NotConfigured),
457                make_detected(AiTool::Cursor, MagConfigStatus::Configured),
458                make_detected(AiTool::Windsurf, MagConfigStatus::NotConfigured),
459            ],
460            not_found: vec![],
461        };
462        let args = SetupArgs {
463            non_interactive: true,
464            tools: None,
465            transport: TransportMode::Command,
466            port: 4242,
467            no_start: true,
468            uninstall: false,
469            force: false,
470        };
471
472        let selected = select_tools(&result, &args).unwrap();
473        assert_eq!(selected.len(), 2);
474        assert_eq!(selected[0].tool, AiTool::ClaudeCode);
475        assert_eq!(selected[1].tool, AiTool::Windsurf);
476    }
477
478    #[test]
479    fn select_tools_with_filter() {
480        let result = DetectionResult {
481            detected: vec![
482                make_detected(AiTool::ClaudeCode, MagConfigStatus::NotConfigured),
483                make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
484                make_detected(AiTool::Windsurf, MagConfigStatus::NotConfigured),
485            ],
486            not_found: vec![],
487        };
488        let args = SetupArgs {
489            non_interactive: true,
490            tools: Some(vec!["cursor".to_string()]),
491            transport: TransportMode::Command,
492            port: 4242,
493            no_start: true,
494            uninstall: false,
495            force: false,
496        };
497
498        let selected = select_tools(&result, &args).unwrap();
499        assert_eq!(selected.len(), 1);
500        assert_eq!(selected[0].tool, AiTool::Cursor);
501    }
502
503    #[test]
504    fn select_tools_force_includes_configured() {
505        let result = DetectionResult {
506            detected: vec![
507                make_detected(AiTool::ClaudeCode, MagConfigStatus::Configured),
508                make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
509            ],
510            not_found: vec![],
511        };
512        let args = SetupArgs {
513            non_interactive: true,
514            tools: None,
515            transport: TransportMode::Command,
516            port: 4242,
517            no_start: true,
518            uninstall: false,
519            force: true,
520        };
521
522        let selected = select_tools(&result, &args).unwrap();
523        assert_eq!(selected.len(), 2);
524    }
525
526    #[test]
527    fn select_tools_all_configured_returns_empty() {
528        let result = DetectionResult {
529            detected: vec![make_detected(
530                AiTool::ClaudeCode,
531                MagConfigStatus::Configured,
532            )],
533            not_found: vec![],
534        };
535        let args = SetupArgs {
536            non_interactive: true,
537            tools: None,
538            transport: TransportMode::Command,
539            port: 4242,
540            no_start: true,
541            uninstall: false,
542            force: false,
543        };
544
545        let selected = select_tools(&result, &args).unwrap();
546        assert!(selected.is_empty());
547    }
548
549    // -----------------------------------------------------------------------
550    // CI / TTY detection
551    // -----------------------------------------------------------------------
552
553    #[test]
554    fn is_ci_checks_env_vars() {
555        // In test environment, CI may or may not be set, but the function
556        // should not panic.
557        let _ = is_ci();
558    }
559
560    // -----------------------------------------------------------------------
561    // Presentation (smoke tests — ensure no panics)
562    // -----------------------------------------------------------------------
563
564    #[test]
565    fn present_detection_empty() {
566        let result = DetectionResult {
567            detected: vec![],
568            not_found: vec![AiTool::ClaudeCode],
569        };
570        present_detection(&result);
571    }
572
573    #[test]
574    fn present_detection_with_tools() {
575        let result = DetectionResult {
576            detected: vec![
577                make_detected(AiTool::ClaudeCode, MagConfigStatus::Configured),
578                make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
579                make_detected(
580                    AiTool::Zed,
581                    MagConfigStatus::Misconfigured("missing source".to_string()),
582                ),
583            ],
584            not_found: vec![AiTool::Windsurf],
585        };
586        present_detection(&result);
587    }
588
589    #[test]
590    fn present_summary_all_variants() {
591        let summary = ConfigureSummary {
592            written: vec!["Claude Code".to_string()],
593            already_current: vec!["Cursor".to_string()],
594            unsupported: vec![("Zed".to_string(), "manual editing required".to_string())],
595            deferred: vec!["Codex".to_string()],
596            errors: vec![("Windsurf".to_string(), "permission denied".to_string())],
597        };
598        present_summary(&summary);
599    }
600
601    // -----------------------------------------------------------------------
602    // Integration: configure_tools with temp home
603    // -----------------------------------------------------------------------
604
605    #[test]
606    fn configure_tools_writes_config() {
607        with_temp_home(|home| {
608            // Create a Claude Code config file
609            let config_path = home.join(".claude.json");
610            std::fs::write(&config_path, "{}").unwrap();
611
612            let dt = DetectedTool {
613                tool: AiTool::ClaudeCode,
614                config_path: config_path.clone(),
615                scope: ConfigScope::Global,
616                mag_status: MagConfigStatus::NotConfigured,
617            };
618
619            let tools: Vec<&DetectedTool> = vec![&dt];
620            let summary = configure_tools(&tools, TransportMode::Command).unwrap();
621
622            assert_eq!(summary.written.len(), 1);
623            assert_eq!(summary.written[0], "Claude Code");
624            assert!(summary.errors.is_empty());
625
626            // Verify the config was written
627            let content = std::fs::read_to_string(&config_path).unwrap();
628            let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
629            assert!(parsed["mcpServers"]["mag"].is_object());
630        });
631    }
632
633    #[test]
634    fn configure_tools_idempotent() {
635        with_temp_home(|home| {
636            // Create a config that already has MAG configured
637            let config_path = home.join(".cursor/mcp.json");
638            std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
639            std::fs::write(
640                &config_path,
641                r#"{"mcpServers":{"mag":{"command":"mag","args":["serve"]}}}"#,
642            )
643            .unwrap();
644
645            let dt = DetectedTool {
646                tool: AiTool::Cursor,
647                config_path: config_path.clone(),
648                scope: ConfigScope::Global,
649                mag_status: MagConfigStatus::Configured,
650            };
651
652            let tools: Vec<&DetectedTool> = vec![&dt];
653            let summary = configure_tools(&tools, TransportMode::Command).unwrap();
654
655            assert_eq!(summary.already_current.len(), 1);
656            assert!(summary.written.is_empty());
657        });
658    }
659
660    #[test]
661    fn configure_tools_zed_unsupported() {
662        let dt = DetectedTool {
663            tool: AiTool::Zed,
664            config_path: PathBuf::from("/fake/zed/settings.json"),
665            scope: ConfigScope::Global,
666            mag_status: MagConfigStatus::NotConfigured,
667        };
668
669        let tools: Vec<&DetectedTool> = vec![&dt];
670        let summary = configure_tools(&tools, TransportMode::Command).unwrap();
671
672        assert_eq!(summary.unsupported.len(), 1);
673    }
674
675    #[test]
676    fn configure_tools_codex_deferred() {
677        let dt = DetectedTool {
678            tool: AiTool::Codex,
679            config_path: PathBuf::from("/fake/codex/config.toml"),
680            scope: ConfigScope::Global,
681            mag_status: MagConfigStatus::NotConfigured,
682        };
683
684        let tools: Vec<&DetectedTool> = vec![&dt];
685        let summary = configure_tools(&tools, TransportMode::Command).unwrap();
686
687        assert_eq!(summary.deferred.len(), 1);
688    }
689
690    // -----------------------------------------------------------------------
691    // Integration: full non-interactive setup flow
692    // -----------------------------------------------------------------------
693
694    #[test]
695    fn full_non_interactive_setup() {
696        with_temp_home(|home| {
697            // Set up a Cursor config file
698            let cursor_dir = home.join(".cursor");
699            std::fs::create_dir_all(&cursor_dir).unwrap();
700            std::fs::write(cursor_dir.join("mcp.json"), "{}").unwrap();
701
702            // Detect
703            let result = detect_phase(None);
704            assert!(
705                result.detected.iter().any(|d| d.tool == AiTool::Cursor),
706                "expected Cursor to be detected"
707            );
708
709            // Select non-interactively
710            let args = SetupArgs {
711                non_interactive: true,
712                tools: None,
713                transport: TransportMode::Command,
714                port: 4242,
715                no_start: true,
716                uninstall: false,
717                force: false,
718            };
719
720            let selected = select_tools(&result, &args).unwrap();
721            assert!(!selected.is_empty(), "expected at least one tool selected");
722
723            // Configure
724            let summary = configure_tools(&selected, TransportMode::Command).unwrap();
725            assert!(
726                !summary.written.is_empty() || !summary.already_current.is_empty(),
727                "expected at least one tool configured"
728            );
729        });
730    }
731
732    // -----------------------------------------------------------------------
733    // Uninstall flow
734    // -----------------------------------------------------------------------
735
736    #[test]
737    fn uninstall_removes_configured_tools() {
738        with_temp_home(|home| {
739            // Set up a Claude Code config with MAG
740            let config_path = home.join(".claude.json");
741            std::fs::write(
742                &config_path,
743                r#"{"mcpServers":{"mag":{"command":"mag","args":["serve"]},"other":{}}}"#,
744            )
745            .unwrap();
746
747            // Run uninstall
748            run_uninstall(None).unwrap();
749
750            // Verify MAG was removed but other config preserved
751            let content = std::fs::read_to_string(&config_path).unwrap();
752            let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
753            assert!(parsed["mcpServers"]["mag"].is_null());
754            assert!(parsed["mcpServers"]["other"].is_object());
755        });
756    }
757
758    #[test]
759    fn uninstall_no_tools_detected() {
760        with_temp_home(|_home| {
761            // No config files exist — should not error
762            run_uninstall(None).unwrap();
763        });
764    }
765
766    // -----------------------------------------------------------------------
767    // Tool filter matching
768    // -----------------------------------------------------------------------
769
770    #[test]
771    fn filter_matches_partial_name() {
772        let result = DetectionResult {
773            detected: vec![
774                make_detected(AiTool::VSCodeCopilot, MagConfigStatus::NotConfigured),
775                make_detected(AiTool::ClaudeCode, MagConfigStatus::NotConfigured),
776            ],
777            not_found: vec![],
778        };
779        let args = SetupArgs {
780            non_interactive: true,
781            tools: Some(vec!["vscode".to_string()]),
782            transport: TransportMode::Command,
783            port: 4242,
784            no_start: true,
785            uninstall: false,
786            force: false,
787        };
788
789        let selected = select_tools(&result, &args).unwrap();
790        assert_eq!(selected.len(), 1);
791        assert_eq!(selected[0].tool, AiTool::VSCodeCopilot);
792    }
793
794    #[test]
795    fn filter_matches_multiple_tools() {
796        let result = DetectionResult {
797            detected: vec![
798                make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
799                make_detected(AiTool::Windsurf, MagConfigStatus::NotConfigured),
800                make_detected(AiTool::ClaudeCode, MagConfigStatus::NotConfigured),
801            ],
802            not_found: vec![],
803        };
804        let args = SetupArgs {
805            non_interactive: true,
806            tools: Some(vec!["cursor".to_string(), "windsurf".to_string()]),
807            transport: TransportMode::Command,
808            port: 4242,
809            no_start: true,
810            uninstall: false,
811            force: false,
812        };
813
814        let selected = select_tools(&result, &args).unwrap();
815        assert_eq!(selected.len(), 2);
816        let tool_names: Vec<_> = selected.iter().map(|d| d.tool).collect();
817        assert!(tool_names.contains(&AiTool::Cursor));
818        assert!(tool_names.contains(&AiTool::Windsurf));
819    }
820}