Skip to main content

sqry_cli/commands/
mcp.rs

1//! MCP server integration setup and status for AI coding tools.
2//!
3//! Provides `sqry mcp setup` and `sqry mcp status` commands that auto-detect
4//! installed AI tools (Claude Code, Codex, Gemini CLI) and configure them
5//! to use sqry-mcp for semantic code search.
6
7use std::collections::BTreeMap;
8use std::fs::{self, File};
9use std::io::{BufWriter, Write};
10use std::path::{Path, PathBuf};
11use std::time::SystemTime;
12
13use anyhow::{Context, Result, bail};
14use serde_json::Value;
15
16use crate::args::{McpCommand, SetupScope, ToolTarget};
17
18// ---------------------------------------------------------------------------
19// Public entry point
20// ---------------------------------------------------------------------------
21
22/// Dispatch MCP subcommands.
23///
24/// # Errors
25///
26/// Returns an error when setup or status operations fail, including config
27/// parsing, filesystem writes, binary discovery, or invalid scope selection.
28pub fn run(command: &McpCommand) -> Result<()> {
29    match command {
30        McpCommand::Setup {
31            tool,
32            scope,
33            workspace_root,
34            force,
35            dry_run,
36            no_backup,
37        } => run_setup(
38            tool,
39            scope,
40            workspace_root.as_deref(),
41            *force,
42            *dry_run,
43            *no_backup,
44        ),
45        McpCommand::Status { json } => run_status(*json),
46    }
47}
48
49// ---------------------------------------------------------------------------
50// Binary resolution
51// ---------------------------------------------------------------------------
52
53/// Find the sqry-mcp binary path.
54///
55/// Search order:
56/// 1. Same directory as the running `sqry` binary
57/// 2. `$PATH` via `which`
58/// 3. `~/.local/bin/sqry-mcp`
59/// 4. `~/.cargo/bin/sqry-mcp`
60fn find_sqry_mcp_binary() -> Result<PathBuf> {
61    // 1. Same directory as current binary
62    if let Ok(exe) = std::env::current_exe()
63        && let Some(dir) = exe.parent()
64    {
65        let candidate = dir.join("sqry-mcp");
66        if candidate.is_file() {
67            return Ok(candidate);
68        }
69    }
70
71    // 2. $PATH
72    if let Ok(path) = which::which("sqry-mcp") {
73        return Ok(path);
74    }
75
76    // 3. ~/.local/bin/
77    if let Some(home) = dirs::home_dir() {
78        let candidate = home.join(".local/bin/sqry-mcp");
79        if candidate.is_file() {
80            return Ok(candidate);
81        }
82    }
83
84    // 4. ~/.cargo/bin/
85    if let Some(home) = dirs::home_dir() {
86        let candidate = home.join(".cargo/bin/sqry-mcp");
87        if candidate.is_file() {
88            return Ok(candidate);
89        }
90    }
91
92    bail!(
93        "Could not find sqry-mcp binary.\n\
94         Install it with: cargo install --path sqry-mcp\n\
95         Or ensure it is on your PATH."
96    );
97}
98
99// ---------------------------------------------------------------------------
100// Workspace root detection
101// ---------------------------------------------------------------------------
102
103/// Detect the workspace root from the current directory.
104///
105/// Walks up from CWD looking for `.sqry/graph` or `.git`.
106fn detect_workspace_root() -> Option<PathBuf> {
107    let cwd = std::env::current_dir().ok()?;
108    let mut dir = cwd.as_path();
109    loop {
110        if dir.join(".sqry/graph").is_dir() || dir.join(".git").exists() {
111            return Some(dir.to_path_buf());
112        }
113        dir = dir.parent()?;
114    }
115}
116
117// ---------------------------------------------------------------------------
118// Per-tool scope resolution
119// ---------------------------------------------------------------------------
120
121/// Resolve the effective scope for Claude Code.
122///
123/// - Auto: project scope when workspace root exists, error otherwise.
124/// - Project: requires workspace root.
125/// - Global: always valid.
126fn resolve_claude_scope(scope: &SetupScope, workspace_root: Option<&Path>) -> Result<SetupScope> {
127    match scope {
128        SetupScope::Auto => {
129            if workspace_root.is_some() {
130                Ok(SetupScope::Project)
131            } else {
132                bail!(
133                    "Not inside a project directory (no .sqry/graph or .git found).\n\
134                     Run from inside a project directory, or use --scope global."
135                );
136            }
137        }
138        SetupScope::Project => {
139            if workspace_root.is_none() {
140                bail!(
141                    "Project scope requires being inside a project directory \
142                     (or use --workspace-root)."
143                );
144            }
145            Ok(SetupScope::Project)
146        }
147        SetupScope::Global => Ok(SetupScope::Global),
148    }
149}
150
151// ---------------------------------------------------------------------------
152// Tool detection
153// ---------------------------------------------------------------------------
154
155/// Check if an AI coding tool is installed on the system.
156///
157/// Detection strategy:
158/// - Claude Code: `claude` binary on PATH or `~/.claude.json` exists
159/// - Codex: `codex` binary on PATH or `~/.codex/` directory exists
160/// - Gemini: `gemini` binary on PATH or `~/.gemini/` directory exists
161fn detect_tool_installed(tool_name: &str) -> bool {
162    match tool_name {
163        "claude" => {
164            which::which("claude").is_ok() || claude_config_path().is_some_and(|p| p.exists())
165        }
166        "codex" => {
167            which::which("codex").is_ok()
168                || codex_config_path().is_some_and(|p| p.parent().is_some_and(Path::exists))
169        }
170        "gemini" => {
171            which::which("gemini").is_ok()
172                || gemini_config_path().is_some_and(|p| p.parent().is_some_and(Path::exists))
173        }
174        _ => false,
175    }
176}
177
178// ---------------------------------------------------------------------------
179// Atomic write helper
180// ---------------------------------------------------------------------------
181
182/// Write content atomically using temp file + fsync + rename.
183///
184/// Mirrors the pattern from `sqry-cli/src/persistence/index.rs`.
185fn atomic_write(path: &Path, content: &[u8], backup: bool) -> Result<()> {
186    // Ensure parent directory exists
187    if let Some(parent) = path.parent()
188        && !parent.exists()
189    {
190        fs::create_dir_all(parent)
191            .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
192    }
193
194    // Backup existing file
195    if backup && path.exists() {
196        let bak = path.with_extension("bak");
197        fs::copy(path, &bak)
198            .with_context(|| format!("Failed to create backup: {}", bak.display()))?;
199    }
200
201    // Write to temp file in same directory
202    let temp_name = format!(
203        "{}.tmp.{}",
204        path.file_name()
205            .and_then(|n| n.to_str())
206            .unwrap_or("config"),
207        std::process::id()
208    );
209    let temp_path = path.with_file_name(temp_name);
210    {
211        let file = File::create(&temp_path)
212            .with_context(|| format!("Failed to create temp file: {}", temp_path.display()))?;
213        let mut writer = BufWriter::new(file);
214        writer.write_all(content)?;
215        writer.flush()?;
216        writer
217            .get_ref()
218            .sync_all()
219            .context("Failed to sync temp file")?;
220    }
221
222    // Atomic rename
223    fs::rename(&temp_path, path)
224        .with_context(|| format!("Failed to rename temp file to: {}", path.display()))?;
225    Ok(())
226}
227
228/// Read a file and return its content + mtime for conflict detection.
229///
230/// Uses a single file handle to avoid TOCTOU: captures mtime from the same
231/// handle used to read content, ensuring consistency.
232fn read_with_mtime(path: &Path) -> Result<(String, SystemTime)> {
233    use std::io::Read;
234    let file = File::open(path).with_context(|| format!("Failed to open: {}", path.display()))?;
235    let mtime = file
236        .metadata()
237        .and_then(|m| m.modified())
238        .with_context(|| format!("Failed to get mtime: {}", path.display()))?;
239    let mut content = String::new();
240    std::io::BufReader::new(file)
241        .read_to_string(&mut content)
242        .with_context(|| format!("Failed to read: {}", path.display()))?;
243    Ok((content, mtime))
244}
245
246/// Check that a file hasn't been modified since we read it.
247fn check_mtime(path: &Path, original_mtime: SystemTime, force: bool) -> Result<()> {
248    if force {
249        return Ok(());
250    }
251    if let Ok(current_mtime) = fs::metadata(path).and_then(|m| m.modified())
252        && current_mtime != original_mtime
253    {
254        bail!(
255            "Config file {} was modified by another process.\n\
256             Re-run the command or use --force to overwrite.",
257            path.display()
258        );
259    }
260    Ok(())
261}
262
263// ---------------------------------------------------------------------------
264// Config path helpers
265// ---------------------------------------------------------------------------
266
267fn claude_config_path() -> Option<PathBuf> {
268    dirs::home_dir().map(|h| h.join(".claude.json"))
269}
270
271fn codex_config_path() -> Option<PathBuf> {
272    dirs::home_dir().map(|h| h.join(".codex/config.toml"))
273}
274
275fn gemini_config_path() -> Option<PathBuf> {
276    dirs::home_dir().map(|h| h.join(".gemini/settings.json"))
277}
278
279fn shim_path() -> Option<PathBuf> {
280    dirs::home_dir().map(|h| h.join(".codex/sqry-mcp-shim.sh"))
281}
282
283// ---------------------------------------------------------------------------
284// Setup command
285// ---------------------------------------------------------------------------
286
287#[allow(clippy::too_many_lines)]
288fn run_setup(
289    tool: &ToolTarget,
290    scope: &SetupScope,
291    workspace_root_override: Option<&Path>,
292    force: bool,
293    dry_run: bool,
294    no_backup: bool,
295) -> Result<()> {
296    // Validate --workspace-root override if provided (before binary lookup
297    // so argument errors are reported immediately)
298    let workspace_root_validated = if let Some(root) = workspace_root_override {
299        if !root.exists() {
300            bail!("Workspace root does not exist: {}", root.display());
301        }
302        if !root.join(".sqry/graph").is_dir() && !root.join(".git").exists() {
303            bail!(
304                "Workspace root must contain .sqry/graph or .git: {}",
305                root.display()
306            );
307        }
308        // Reject --workspace-root for Codex/Gemini (global-only tools)
309        match tool {
310            ToolTarget::Codex | ToolTarget::Gemini => {
311                bail!(
312                    "Codex/Gemini use global configs -- setting a workspace root would \
313                     pin to one repo. Use CWD-based discovery instead (start your tool \
314                     from the project directory)."
315                );
316            }
317            ToolTarget::All | ToolTarget::Claude => {}
318        }
319        Some(root.to_path_buf())
320    } else {
321        None
322    };
323
324    let binary = find_sqry_mcp_binary()?;
325    let binary_str = binary.to_string_lossy();
326
327    // Auto-detect workspace root from CWD (used for Claude scope decisions)
328    let detected_root = detect_workspace_root();
329    let workspace_root = workspace_root_validated.or(detected_root);
330
331    let mut configured = Vec::new();
332    let mut skipped = Vec::new();
333    let backup = !no_backup;
334
335    // Configure each tool with per-tool scope computation
336    let should_configure_claude = matches!(tool, ToolTarget::All | ToolTarget::Claude);
337    let should_configure_codex = matches!(tool, ToolTarget::All | ToolTarget::Codex);
338    let should_configure_gemini = matches!(tool, ToolTarget::All | ToolTarget::Gemini);
339
340    if should_configure_claude {
341        if !detect_tool_installed("claude") && matches!(tool, ToolTarget::All) {
342            skipped.push((
343                "Claude Code",
344                "not detected (claude not found on PATH, no ~/.claude.json)".to_string(),
345            ));
346        } else {
347            // Claude scope: auto→project when root exists, auto→error when no root
348            match resolve_claude_scope(scope, workspace_root.as_deref()) {
349                Ok(claude_scope) => {
350                    match configure_claude(
351                        &binary_str,
352                        &claude_scope,
353                        workspace_root.as_deref(),
354                        force,
355                        dry_run,
356                        backup,
357                    ) {
358                        Ok(msg) => configured.push(("Claude Code", msg)),
359                        Err(e) => {
360                            if matches!(tool, ToolTarget::All) {
361                                skipped.push(("Claude Code", format!("{e:#}")));
362                            } else {
363                                return Err(e);
364                            }
365                        }
366                    }
367                }
368                Err(e) => {
369                    if matches!(tool, ToolTarget::All) {
370                        skipped.push(("Claude Code", format!("{e:#}")));
371                    } else {
372                        return Err(e);
373                    }
374                }
375            }
376        }
377    }
378
379    if should_configure_codex {
380        // Codex: always global (no workspace root requirement)
381        if !detect_tool_installed("codex") && matches!(tool, ToolTarget::All) {
382            skipped.push((
383                "Codex",
384                "not detected (codex not found on PATH)".to_string(),
385            ));
386        } else {
387            match configure_codex(&binary_str, force, dry_run, backup) {
388                Ok(msg) => configured.push(("Codex", msg)),
389                Err(e) => {
390                    if matches!(tool, ToolTarget::All) {
391                        skipped.push(("Codex", format!("{e:#}")));
392                    } else {
393                        return Err(e);
394                    }
395                }
396            }
397        }
398    }
399
400    if should_configure_gemini {
401        // Gemini: always global (no workspace root requirement)
402        if !detect_tool_installed("gemini") && matches!(tool, ToolTarget::All) {
403            skipped.push((
404                "Gemini",
405                "not detected (gemini not found on PATH)".to_string(),
406            ));
407        } else {
408            match configure_gemini(&binary_str, force, dry_run, backup) {
409                Ok(msg) => configured.push(("Gemini", msg)),
410                Err(e) => {
411                    if matches!(tool, ToolTarget::All) {
412                        skipped.push(("Gemini", format!("{e:#}")));
413                    } else {
414                        return Err(e);
415                    }
416                }
417            }
418        }
419    }
420
421    // Print summary
422    if dry_run {
423        println!("Dry run complete. No files were modified.");
424    } else {
425        println!("sqry MCP Setup Complete");
426        println!();
427    }
428
429    for (tool_name, msg) in &configured {
430        println!("  {tool_name}: {msg}");
431    }
432    for (tool_name, msg) in &skipped {
433        println!("  {tool_name}: skipped ({msg})");
434    }
435
436    if configured.is_empty() && skipped.is_empty() {
437        println!("  No tools configured.");
438    }
439
440    // CWD dependence note for Codex/Gemini
441    let codex_gemini_configured = configured
442        .iter()
443        .any(|(name, _)| *name == "Codex" || *name == "Gemini");
444    if codex_gemini_configured {
445        println!();
446        println!(
447            "Note: Codex/Gemini use CWD-based workspace discovery. \
448             Start these tools from within your project directory for \
449             sqry to resolve the correct workspace."
450        );
451    }
452
453    Ok(())
454}
455
456// ---------------------------------------------------------------------------
457// Claude Code configuration
458// ---------------------------------------------------------------------------
459
460fn configure_claude(
461    binary: &str,
462    scope: &SetupScope,
463    workspace_root: Option<&Path>,
464    force: bool,
465    dry_run: bool,
466    backup: bool,
467) -> Result<String> {
468    let config_path = claude_config_path().context("Could not determine home directory")?;
469
470    // Build the MCP server entry
471    let mut entry = serde_json::json!({
472        "type": "stdio",
473        "command": binary,
474        "args": []
475    });
476
477    let scope_label;
478
479    match scope {
480        SetupScope::Project | SetupScope::Auto => {
481            let root = workspace_root.context("No workspace root for project scope")?;
482            let root_str = root.to_string_lossy();
483            entry["env"] = serde_json::json!({
484                "SQRY_MCP_WORKSPACE_ROOT": root_str.as_ref()
485            });
486            scope_label = format!("project ({root_str})");
487
488            if dry_run {
489                println!("Would write to: {}", config_path.display());
490                println!("  Path: projects[\"{root_str}\"].mcpServers.sqry");
491                println!("  Entry: {}", serde_json::to_string_pretty(&entry)?);
492                return Ok(format!("would configure ({scope_label})"));
493            }
494
495            write_claude_project_entry(&config_path, &root_str, &entry, force, backup)?;
496        }
497        SetupScope::Global => {
498            scope_label = "global".to_string();
499
500            if dry_run {
501                println!("Would write to: {}", config_path.display());
502                println!("  Path: mcpServers.sqry");
503                println!("  Entry: {}", serde_json::to_string_pretty(&entry)?);
504                return Ok(format!("would configure ({scope_label})"));
505            }
506
507            write_claude_global_entry(&config_path, &entry, force, backup)?;
508        }
509    }
510
511    Ok(format!("configured ({scope_label})"))
512}
513
514fn write_claude_project_entry(
515    config_path: &Path,
516    project_path: &str,
517    entry: &Value,
518    force: bool,
519    backup: bool,
520) -> Result<()> {
521    let (mut config, mtime) = if config_path.exists() {
522        let (content, mtime) = read_with_mtime(config_path)?;
523        let config: Value =
524            serde_json::from_str(&content).context("Failed to parse ~/.claude.json")?;
525        (config, Some(mtime))
526    } else {
527        (serde_json::json!({}), None)
528    };
529
530    // Navigate/create: projects[project_path].mcpServers.sqry
531    let projects = config
532        .as_object_mut()
533        .context("~/.claude.json is not a JSON object")?
534        .entry("projects")
535        .or_insert_with(|| serde_json::json!({}));
536
537    let project = projects
538        .as_object_mut()
539        .context("projects is not a JSON object")?
540        .entry(project_path)
541        .or_insert_with(|| serde_json::json!({}));
542
543    let mcp_servers = project
544        .as_object_mut()
545        .context("project entry is not a JSON object")?
546        .entry("mcpServers")
547        .or_insert_with(|| serde_json::json!({}));
548
549    let servers = mcp_servers
550        .as_object_mut()
551        .context("mcpServers is not a JSON object")?;
552
553    if servers.contains_key("sqry") && !force {
554        bail!(
555            "Claude Code project entry for sqry already exists at projects[\"{project_path}\"].\n\
556             Use --force to overwrite."
557        );
558    }
559
560    servers.insert("sqry".to_string(), entry.clone());
561
562    // Write back
563    if let Some(mt) = mtime {
564        check_mtime(config_path, mt, force)?;
565    }
566    let output = serde_json::to_string_pretty(&config)?;
567    atomic_write(config_path, output.as_bytes(), backup)?;
568    Ok(())
569}
570
571fn write_claude_global_entry(
572    config_path: &Path,
573    entry: &Value,
574    force: bool,
575    backup: bool,
576) -> Result<()> {
577    let (mut config, mtime) = if config_path.exists() {
578        let (content, mtime) = read_with_mtime(config_path)?;
579        let config: Value =
580            serde_json::from_str(&content).context("Failed to parse ~/.claude.json")?;
581        (config, Some(mtime))
582    } else {
583        (serde_json::json!({}), None)
584    };
585
586    let mcp_servers = config
587        .as_object_mut()
588        .context("~/.claude.json is not a JSON object")?
589        .entry("mcpServers")
590        .or_insert_with(|| serde_json::json!({}));
591
592    let servers = mcp_servers
593        .as_object_mut()
594        .context("mcpServers is not a JSON object")?;
595
596    if servers.contains_key("sqry") && !force {
597        bail!(
598            "Claude Code global sqry entry already exists.\n\
599             Use --force to overwrite."
600        );
601    }
602
603    servers.insert("sqry".to_string(), entry.clone());
604
605    if let Some(mt) = mtime {
606        check_mtime(config_path, mt, force)?;
607    }
608    let output = serde_json::to_string_pretty(&config)?;
609    atomic_write(config_path, output.as_bytes(), backup)?;
610    Ok(())
611}
612
613// ---------------------------------------------------------------------------
614// Codex configuration
615// ---------------------------------------------------------------------------
616
617fn configure_codex(binary: &str, force: bool, dry_run: bool, backup: bool) -> Result<String> {
618    let config_path = codex_config_path().context("Could not determine home directory")?;
619
620    if dry_run {
621        println!("Would write to: {}", config_path.display());
622        println!("  Section: [mcp_servers.sqry]");
623        println!("  command = \"{binary}\"");
624        return Ok("would configure (global, CWD discovery)".to_string());
625    }
626
627    if !config_path.exists() {
628        // Create minimal config
629        let content = format!("[mcp_servers.sqry]\ncommand = \"{binary}\"\n");
630        atomic_write(&config_path, content.as_bytes(), false)?;
631        return Ok("configured (global, CWD discovery) [created new config]".to_string());
632    }
633
634    let (content, mtime) = read_with_mtime(&config_path)?;
635
636    // Use toml_edit for comment-preserving TOML editing
637    let mut doc: toml_edit::DocumentMut = content
638        .parse()
639        .context("Failed to parse ~/.codex/config.toml")?;
640
641    // Check if sqry entry exists
642    let has_sqry = doc.get("mcp_servers").and_then(|s| s.get("sqry")).is_some();
643
644    if has_sqry && !force {
645        bail!(
646            "Codex sqry MCP entry already exists.\n\
647             Use --force to overwrite."
648        );
649    }
650
651    // Ensure [mcp_servers] table exists
652    if doc.get("mcp_servers").is_none() {
653        doc["mcp_servers"] = toml_edit::Item::Table(toml_edit::Table::new());
654    }
655
656    // Create [mcp_servers.sqry] table
657    let mut sqry_table = toml_edit::Table::new();
658    sqry_table.insert("command", toml_edit::value(binary));
659
660    // Remove any existing env section (legacy SQRY_MCP_WORKSPACE_ROOT)
661    doc["mcp_servers"]["sqry"] = toml_edit::Item::Table(sqry_table);
662
663    check_mtime(&config_path, mtime, force)?;
664    atomic_write(&config_path, doc.to_string().as_bytes(), backup)?;
665
666    Ok("configured (global, CWD discovery)".to_string())
667}
668
669// ---------------------------------------------------------------------------
670// Gemini configuration
671// ---------------------------------------------------------------------------
672
673fn configure_gemini(binary: &str, force: bool, dry_run: bool, backup: bool) -> Result<String> {
674    let config_path = gemini_config_path().context("Could not determine home directory")?;
675
676    let entry = serde_json::json!({
677        "command": binary,
678        "args": [],
679        "env": {}
680    });
681
682    if dry_run {
683        println!("Would write to: {}", config_path.display());
684        println!("  Path: mcpServers.sqry");
685        println!("  Entry: {}", serde_json::to_string_pretty(&entry)?);
686        return Ok("would configure (global, CWD discovery)".to_string());
687    }
688
689    let (mut config, mtime) = if config_path.exists() {
690        let (content, mtime) = read_with_mtime(&config_path)?;
691        let config: Value =
692            serde_json::from_str(&content).context("Failed to parse ~/.gemini/settings.json")?;
693        (config, Some(mtime))
694    } else {
695        (serde_json::json!({}), None)
696    };
697
698    let mcp_servers = config
699        .as_object_mut()
700        .context("~/.gemini/settings.json is not a JSON object")?
701        .entry("mcpServers")
702        .or_insert_with(|| serde_json::json!({}));
703
704    let servers = mcp_servers
705        .as_object_mut()
706        .context("mcpServers is not a JSON object")?;
707
708    if servers.contains_key("sqry") && !force {
709        bail!(
710            "Gemini sqry MCP entry already exists.\n\
711             Use --force to overwrite."
712        );
713    }
714
715    servers.insert("sqry".to_string(), entry);
716
717    if let Some(mt) = mtime {
718        check_mtime(&config_path, mt, force)?;
719    }
720    let output = serde_json::to_string_pretty(&config)?;
721    atomic_write(&config_path, output.as_bytes(), backup)?;
722
723    Ok("configured (global, CWD discovery)".to_string())
724}
725
726// ---------------------------------------------------------------------------
727// Status command
728// ---------------------------------------------------------------------------
729
730fn run_status(json_output: bool) -> Result<()> {
731    let binary = find_sqry_mcp_binary().ok();
732    let binary_display = binary.as_ref().map_or_else(
733        || "not found".to_string(),
734        |p| p.to_string_lossy().to_string(),
735    );
736
737    if json_output {
738        print_status_json(&binary_display)?;
739    } else {
740        print_status_human(&binary_display);
741    }
742
743    Ok(())
744}
745
746fn print_status_human(binary: &str) {
747    println!("sqry MCP Status\n");
748    println!("Binary:  {binary}");
749    println!();
750
751    // Claude Code — continue on error
752    if let Err(e) = print_claude_status_human() {
753        println!("Claude Code:  error reading config ({e:#})");
754    }
755    println!();
756
757    // Codex — continue on error
758    if let Err(e) = print_codex_status_human() {
759        println!("Codex:  error reading config ({e:#})");
760    }
761    println!();
762
763    // Gemini — continue on error
764    if let Err(e) = print_gemini_status_human() {
765        println!("Gemini:  error reading config ({e:#})");
766    }
767
768    // Shim detection
769    if let Some(shim) = shim_path()
770        && shim.exists()
771    {
772        println!();
773        println!("Warning: Legacy shim detected at {}", shim.display());
774        println!("  The shim is no longer needed (rmcp 0.11.0 handles MCP protocol natively).");
775        println!("  You can safely remove it.");
776    }
777}
778
779fn print_claude_status_human() -> Result<()> {
780    let Some(config_path) = claude_config_path() else {
781        println!("Claude Code:  config path unknown");
782        return Ok(());
783    };
784
785    if !config_path.exists() {
786        println!("Claude Code (~/.claude.json):  not detected");
787        return Ok(());
788    }
789
790    println!("Claude Code (~/.claude.json):");
791
792    let content = fs::read_to_string(&config_path)?;
793    let config: Value = serde_json::from_str(&content).context("Failed to parse ~/.claude.json")?;
794
795    // Global entry
796    if let Some(cmd) = config
797        .get("mcpServers")
798        .and_then(|s| s.get("sqry"))
799        .and_then(|e| e.get("command"))
800        .and_then(Value::as_str)
801    {
802        println!("  Global:  configured");
803        println!("    Command: {cmd}");
804        if let Some(root) = config
805            .get("mcpServers")
806            .and_then(|s| s.get("sqry"))
807            .and_then(|e| e.get("env"))
808            .and_then(|e| e.get("SQRY_MCP_WORKSPACE_ROOT"))
809            .and_then(Value::as_str)
810        {
811            println!("    Workspace root: {root}");
812        }
813    } else {
814        println!("  Global:  not configured");
815    }
816
817    // Per-project entries
818    if let Some(projects) = config.get("projects").and_then(Value::as_object) {
819        for (path, project) in projects {
820            if let Some(cmd) = project
821                .get("mcpServers")
822                .and_then(|s| s.get("sqry"))
823                .and_then(|e| e.get("command"))
824                .and_then(Value::as_str)
825            {
826                println!("  Project ({path}):");
827                println!("    configured");
828                println!("    Command: {cmd}");
829                if let Some(root) = project
830                    .get("mcpServers")
831                    .and_then(|s| s.get("sqry"))
832                    .and_then(|e| e.get("env"))
833                    .and_then(|e| e.get("SQRY_MCP_WORKSPACE_ROOT"))
834                    .and_then(Value::as_str)
835                {
836                    println!("    Workspace root: {root}");
837                }
838
839                // Drift detection: project entry overrides global
840                if config
841                    .get("mcpServers")
842                    .and_then(|s| s.get("sqry"))
843                    .is_some()
844                {
845                    println!("    Note: Project entry overrides global for this project");
846                }
847            }
848        }
849    }
850
851    Ok(())
852}
853
854fn print_codex_status_human() -> Result<()> {
855    let Some(config_path) = codex_config_path() else {
856        println!("Codex:  config path unknown");
857        return Ok(());
858    };
859
860    if !config_path.exists() {
861        println!("Codex (~/.codex/config.toml):  not detected");
862        return Ok(());
863    }
864
865    println!("Codex (~/.codex/config.toml):");
866
867    let content = fs::read_to_string(&config_path)?;
868    let doc: toml_edit::DocumentMut = content.parse().context("Failed to parse config.toml")?;
869
870    if let Some(cmd) = doc
871        .get("mcp_servers")
872        .and_then(|s| s.get("sqry"))
873        .and_then(|t| t.get("command"))
874        .and_then(|v| v.as_str())
875    {
876        println!("  configured");
877        println!("  Command: {cmd}");
878
879        if let Some(root) = doc
880            .get("mcp_servers")
881            .and_then(|s| s.get("sqry"))
882            .and_then(|t| t.get("env"))
883            .and_then(|e| e.get("SQRY_MCP_WORKSPACE_ROOT"))
884            .and_then(|v| v.as_str())
885        {
886            println!("  Workspace root: {root}");
887        } else {
888            println!("  Workspace root: (CWD discovery)");
889            println!("  Note: Codex must be started from within a project directory");
890        }
891    } else {
892        println!("  sqry not configured");
893    }
894
895    Ok(())
896}
897
898fn print_gemini_status_human() -> Result<()> {
899    let Some(config_path) = gemini_config_path() else {
900        println!("Gemini:  config path unknown");
901        return Ok(());
902    };
903
904    if !config_path.exists() {
905        println!("Gemini (~/.gemini/settings.json):  not detected");
906        return Ok(());
907    }
908
909    println!("Gemini (~/.gemini/settings.json):");
910
911    let content = fs::read_to_string(&config_path)?;
912    let config: Value =
913        serde_json::from_str(&content).context("Failed to parse ~/.gemini/settings.json")?;
914
915    if let Some(cmd) = config
916        .get("mcpServers")
917        .and_then(|s| s.get("sqry"))
918        .and_then(|e| e.get("command"))
919        .and_then(Value::as_str)
920    {
921        println!("  configured");
922        println!("  Command: {cmd}");
923
924        if let Some(root) = config
925            .get("mcpServers")
926            .and_then(|s| s.get("sqry"))
927            .and_then(|e| e.get("env"))
928            .and_then(|e| e.get("SQRY_MCP_WORKSPACE_ROOT"))
929            .and_then(Value::as_str)
930        {
931            println!("  Workspace root: {root}");
932        } else {
933            println!("  Workspace root: (CWD discovery)");
934            println!("  Note: Gemini must be started from within a project directory");
935        }
936    } else {
937        println!("  sqry not configured");
938    }
939
940    Ok(())
941}
942
943// ---------------------------------------------------------------------------
944// JSON status output
945// ---------------------------------------------------------------------------
946
947fn print_status_json(binary: &str) -> Result<()> {
948    let mut output = serde_json::json!({
949        "binary": binary,
950        "tools": {}
951    });
952
953    let tools = output["tools"].as_object_mut().unwrap();
954
955    // Each tool is resilient — report error as JSON instead of failing
956    tools.insert(
957        "claude".to_string(),
958        claude_status_json().unwrap_or_else(
959            |e| serde_json::json!({"configured": false, "error": format!("{e:#}")}),
960        ),
961    );
962
963    tools.insert(
964        "codex".to_string(),
965        codex_status_json().unwrap_or_else(
966            |e| serde_json::json!({"configured": false, "error": format!("{e:#}")}),
967        ),
968    );
969
970    tools.insert(
971        "gemini".to_string(),
972        gemini_status_json().unwrap_or_else(
973            |e| serde_json::json!({"configured": false, "error": format!("{e:#}")}),
974        ),
975    );
976
977    // Shim
978    if let Some(shim) = shim_path()
979        && shim.exists()
980    {
981        output["shim_detected"] = Value::String(shim.to_string_lossy().to_string());
982    }
983
984    println!("{}", serde_json::to_string_pretty(&output)?);
985    Ok(())
986}
987
988fn claude_status_json() -> Result<Value> {
989    let Some(config_path) = claude_config_path() else {
990        return Ok(serde_json::json!({"configured": false}));
991    };
992
993    if !config_path.exists() {
994        return Ok(serde_json::json!({
995            "config_path": config_path.to_string_lossy(),
996            "configured": false
997        }));
998    }
999
1000    let content = fs::read_to_string(&config_path)?;
1001    let config: Value = serde_json::from_str(&content)?;
1002
1003    let global = config
1004        .get("mcpServers")
1005        .and_then(|s| s.get("sqry"))
1006        .map_or_else(
1007            || serde_json::json!({"configured": false}),
1008            |entry| {
1009                serde_json::json!({
1010                    "configured": true,
1011                    "command": entry.get("command").and_then(Value::as_str).unwrap_or(""),
1012                    "workspace_root": entry.get("env")
1013                        .and_then(|e| e.get("SQRY_MCP_WORKSPACE_ROOT"))
1014                        .and_then(Value::as_str)
1015                })
1016            },
1017        );
1018
1019    let mut projects = BTreeMap::new();
1020    if let Some(proj_map) = config.get("projects").and_then(Value::as_object) {
1021        for (path, project) in proj_map {
1022            if let Some(entry) = project.get("mcpServers").and_then(|s| s.get("sqry")) {
1023                projects.insert(
1024                    path.clone(),
1025                    serde_json::json!({
1026                        "configured": true,
1027                        "command": entry.get("command").and_then(Value::as_str).unwrap_or(""),
1028                        "workspace_root": entry.get("env")
1029                            .and_then(|e| e.get("SQRY_MCP_WORKSPACE_ROOT"))
1030                            .and_then(Value::as_str)
1031                    }),
1032                );
1033            }
1034        }
1035    }
1036
1037    Ok(serde_json::json!({
1038        "config_path": config_path.to_string_lossy(),
1039        "global": global,
1040        "projects": projects
1041    }))
1042}
1043
1044fn codex_status_json() -> Result<Value> {
1045    let Some(config_path) = codex_config_path() else {
1046        return Ok(serde_json::json!({"configured": false}));
1047    };
1048
1049    if !config_path.exists() {
1050        return Ok(serde_json::json!({
1051            "config_path": config_path.to_string_lossy(),
1052            "configured": false
1053        }));
1054    }
1055
1056    let content = fs::read_to_string(&config_path)?;
1057    let doc: toml_edit::DocumentMut = content.parse()?;
1058
1059    let configured = doc
1060        .get("mcp_servers")
1061        .and_then(|s| s.get("sqry"))
1062        .and_then(|t| t.get("command"))
1063        .and_then(|v| v.as_str())
1064        .is_some();
1065
1066    let command = doc
1067        .get("mcp_servers")
1068        .and_then(|s| s.get("sqry"))
1069        .and_then(|t| t.get("command"))
1070        .and_then(|v| v.as_str())
1071        .unwrap_or("");
1072
1073    let workspace_root = doc
1074        .get("mcp_servers")
1075        .and_then(|s| s.get("sqry"))
1076        .and_then(|t| t.get("env"))
1077        .and_then(|e| e.get("SQRY_MCP_WORKSPACE_ROOT"))
1078        .and_then(|v| v.as_str());
1079
1080    Ok(serde_json::json!({
1081        "config_path": config_path.to_string_lossy(),
1082        "configured": configured,
1083        "command": command,
1084        "workspace_root": workspace_root
1085    }))
1086}
1087
1088fn gemini_status_json() -> Result<Value> {
1089    let Some(config_path) = gemini_config_path() else {
1090        return Ok(serde_json::json!({"configured": false}));
1091    };
1092
1093    if !config_path.exists() {
1094        return Ok(serde_json::json!({
1095            "config_path": config_path.to_string_lossy(),
1096            "configured": false
1097        }));
1098    }
1099
1100    let content = fs::read_to_string(&config_path)?;
1101    let config: Value = serde_json::from_str(&content)?;
1102
1103    let configured = config
1104        .get("mcpServers")
1105        .and_then(|s| s.get("sqry"))
1106        .and_then(|e| e.get("command"))
1107        .and_then(Value::as_str)
1108        .is_some();
1109
1110    let command = config
1111        .get("mcpServers")
1112        .and_then(|s| s.get("sqry"))
1113        .and_then(|e| e.get("command"))
1114        .and_then(Value::as_str)
1115        .unwrap_or("");
1116
1117    let workspace_root = config
1118        .get("mcpServers")
1119        .and_then(|s| s.get("sqry"))
1120        .and_then(|e| e.get("env"))
1121        .and_then(|e| e.get("SQRY_MCP_WORKSPACE_ROOT"))
1122        .and_then(Value::as_str);
1123
1124    Ok(serde_json::json!({
1125        "config_path": config_path.to_string_lossy(),
1126        "configured": configured,
1127        "command": command,
1128        "workspace_root": workspace_root
1129    }))
1130}
1131
1132// ---------------------------------------------------------------------------
1133// Tests
1134// ---------------------------------------------------------------------------
1135
1136#[cfg(test)]
1137mod tests {
1138    use super::*;
1139    use tempfile::TempDir;
1140
1141    // -- Scope resolution tests --
1142
1143    #[test]
1144    fn test_resolve_claude_scope_auto_with_root() {
1145        let tmp = TempDir::new().unwrap();
1146        let scope = resolve_claude_scope(&SetupScope::Auto, Some(tmp.path())).unwrap();
1147        assert!(matches!(scope, SetupScope::Project));
1148    }
1149
1150    #[test]
1151    fn test_resolve_claude_scope_auto_without_root() {
1152        let result = resolve_claude_scope(&SetupScope::Auto, None);
1153        assert!(result.is_err());
1154        let msg = result.unwrap_err().to_string();
1155        assert!(msg.contains("Not inside a project directory"));
1156    }
1157
1158    #[test]
1159    fn test_resolve_claude_scope_project_with_root() {
1160        let tmp = TempDir::new().unwrap();
1161        let scope = resolve_claude_scope(&SetupScope::Project, Some(tmp.path())).unwrap();
1162        assert!(matches!(scope, SetupScope::Project));
1163    }
1164
1165    #[test]
1166    fn test_resolve_claude_scope_project_without_root() {
1167        let result = resolve_claude_scope(&SetupScope::Project, None);
1168        assert!(result.is_err());
1169        let msg = result.unwrap_err().to_string();
1170        assert!(msg.contains("Project scope requires"));
1171    }
1172
1173    #[test]
1174    fn test_resolve_claude_scope_global_no_root_needed() {
1175        let scope = resolve_claude_scope(&SetupScope::Global, None).unwrap();
1176        assert!(matches!(scope, SetupScope::Global));
1177    }
1178
1179    // -- Atomic write tests --
1180
1181    #[test]
1182    fn test_atomic_write_creates_file() {
1183        let tmp = TempDir::new().unwrap();
1184        let path = tmp.path().join("test.json");
1185        atomic_write(&path, b"hello", false).unwrap();
1186        assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
1187    }
1188
1189    #[test]
1190    fn test_atomic_write_creates_parent_dirs() {
1191        let tmp = TempDir::new().unwrap();
1192        let path = tmp.path().join("nested/dir/config.json");
1193        atomic_write(&path, b"{}", false).unwrap();
1194        assert_eq!(fs::read_to_string(&path).unwrap(), "{}");
1195    }
1196
1197    #[test]
1198    fn test_atomic_write_with_backup() {
1199        let tmp = TempDir::new().unwrap();
1200        let path = tmp.path().join("test.json");
1201        fs::write(&path, "original").unwrap();
1202
1203        atomic_write(&path, b"updated", true).unwrap();
1204
1205        assert_eq!(fs::read_to_string(&path).unwrap(), "updated");
1206        let bak = path.with_extension("bak");
1207        assert!(bak.exists());
1208        assert_eq!(fs::read_to_string(&bak).unwrap(), "original");
1209    }
1210
1211    #[test]
1212    fn test_atomic_write_without_backup() {
1213        let tmp = TempDir::new().unwrap();
1214        let path = tmp.path().join("test.json");
1215        fs::write(&path, "original").unwrap();
1216
1217        atomic_write(&path, b"updated", false).unwrap();
1218
1219        assert_eq!(fs::read_to_string(&path).unwrap(), "updated");
1220        let bak = path.with_extension("bak");
1221        assert!(!bak.exists());
1222    }
1223
1224    // -- Mtime conflict detection tests --
1225
1226    #[test]
1227    fn test_read_with_mtime_uses_same_handle() {
1228        let tmp = TempDir::new().unwrap();
1229        let path = tmp.path().join("test.txt");
1230        fs::write(&path, "content").unwrap();
1231
1232        let (content, mtime) = read_with_mtime(&path).unwrap();
1233        assert_eq!(content, "content");
1234
1235        // Mtime should match metadata from the same file
1236        let actual_mtime = fs::metadata(&path).unwrap().modified().unwrap();
1237        assert_eq!(mtime, actual_mtime);
1238    }
1239
1240    #[test]
1241    fn test_check_mtime_unchanged_passes() {
1242        let tmp = TempDir::new().unwrap();
1243        let path = tmp.path().join("test.txt");
1244        fs::write(&path, "content").unwrap();
1245
1246        let (_, mtime) = read_with_mtime(&path).unwrap();
1247        check_mtime(&path, mtime, false).unwrap();
1248    }
1249
1250    #[test]
1251    fn test_check_mtime_changed_fails() {
1252        let tmp = TempDir::new().unwrap();
1253        let path = tmp.path().join("test.txt");
1254        fs::write(&path, "content").unwrap();
1255
1256        let (_, mtime) = read_with_mtime(&path).unwrap();
1257
1258        // Modify the file after a brief delay
1259        std::thread::sleep(std::time::Duration::from_millis(50));
1260        fs::write(&path, "modified").unwrap();
1261
1262        let result = check_mtime(&path, mtime, false);
1263        assert!(result.is_err());
1264        assert!(
1265            result
1266                .unwrap_err()
1267                .to_string()
1268                .contains("modified by another process")
1269        );
1270    }
1271
1272    #[test]
1273    fn test_check_mtime_force_bypasses_conflict() {
1274        let tmp = TempDir::new().unwrap();
1275        let path = tmp.path().join("test.txt");
1276        fs::write(&path, "content").unwrap();
1277
1278        let (_, mtime) = read_with_mtime(&path).unwrap();
1279
1280        std::thread::sleep(std::time::Duration::from_millis(50));
1281        fs::write(&path, "modified").unwrap();
1282
1283        // With force, should succeed despite mtime mismatch
1284        check_mtime(&path, mtime, true).unwrap();
1285    }
1286
1287    // -- Claude config write tests --
1288
1289    #[test]
1290    fn test_write_claude_project_entry_new_file() {
1291        let tmp = TempDir::new().unwrap();
1292        let config_path = tmp.path().join("claude.json");
1293
1294        let entry = serde_json::json!({
1295            "type": "stdio",
1296            "command": "/usr/bin/sqry-mcp",
1297            "args": [],
1298            "env": { "SQRY_MCP_WORKSPACE_ROOT": "/my/project" }
1299        });
1300
1301        write_claude_project_entry(&config_path, "/my/project", &entry, false, false).unwrap();
1302
1303        let content: Value =
1304            serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
1305        assert_eq!(
1306            content["projects"]["/my/project"]["mcpServers"]["sqry"]["command"],
1307            "/usr/bin/sqry-mcp"
1308        );
1309        assert_eq!(
1310            content["projects"]["/my/project"]["mcpServers"]["sqry"]["env"]["SQRY_MCP_WORKSPACE_ROOT"],
1311            "/my/project"
1312        );
1313    }
1314
1315    #[test]
1316    fn test_write_claude_project_entry_exists_no_force() {
1317        let tmp = TempDir::new().unwrap();
1318        let config_path = tmp.path().join("claude.json");
1319
1320        let existing = serde_json::json!({
1321            "projects": {
1322                "/my/project": {
1323                    "mcpServers": {
1324                        "sqry": { "command": "old" }
1325                    }
1326                }
1327            }
1328        });
1329        fs::write(
1330            &config_path,
1331            serde_json::to_string_pretty(&existing).unwrap(),
1332        )
1333        .unwrap();
1334
1335        let entry = serde_json::json!({ "command": "new" });
1336        let result = write_claude_project_entry(&config_path, "/my/project", &entry, false, false);
1337        assert!(result.is_err());
1338        assert!(result.unwrap_err().to_string().contains("already exists"));
1339    }
1340
1341    #[test]
1342    fn test_write_claude_project_entry_exists_with_force() {
1343        let tmp = TempDir::new().unwrap();
1344        let config_path = tmp.path().join("claude.json");
1345
1346        let existing = serde_json::json!({
1347            "projects": {
1348                "/my/project": {
1349                    "mcpServers": {
1350                        "sqry": { "command": "old" }
1351                    }
1352                }
1353            }
1354        });
1355        fs::write(
1356            &config_path,
1357            serde_json::to_string_pretty(&existing).unwrap(),
1358        )
1359        .unwrap();
1360
1361        let entry = serde_json::json!({ "command": "new" });
1362        write_claude_project_entry(&config_path, "/my/project", &entry, true, false).unwrap();
1363
1364        let content: Value =
1365            serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
1366        assert_eq!(
1367            content["projects"]["/my/project"]["mcpServers"]["sqry"]["command"],
1368            "new"
1369        );
1370    }
1371
1372    #[test]
1373    fn test_write_claude_global_entry_new_file() {
1374        let tmp = TempDir::new().unwrap();
1375        let config_path = tmp.path().join("claude.json");
1376
1377        let entry = serde_json::json!({
1378            "type": "stdio",
1379            "command": "/usr/bin/sqry-mcp",
1380            "args": []
1381        });
1382
1383        write_claude_global_entry(&config_path, &entry, false, false).unwrap();
1384
1385        let content: Value =
1386            serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
1387        assert_eq!(
1388            content["mcpServers"]["sqry"]["command"],
1389            "/usr/bin/sqry-mcp"
1390        );
1391    }
1392
1393    #[test]
1394    fn test_write_claude_global_entry_exists_no_force() {
1395        let tmp = TempDir::new().unwrap();
1396        let config_path = tmp.path().join("claude.json");
1397
1398        let existing = serde_json::json!({
1399            "mcpServers": {
1400                "sqry": { "command": "old" }
1401            }
1402        });
1403        fs::write(
1404            &config_path,
1405            serde_json::to_string_pretty(&existing).unwrap(),
1406        )
1407        .unwrap();
1408
1409        let entry = serde_json::json!({ "command": "new" });
1410        let result = write_claude_global_entry(&config_path, &entry, false, false);
1411        assert!(result.is_err());
1412        assert!(result.unwrap_err().to_string().contains("already exists"));
1413    }
1414
1415    #[test]
1416    fn test_write_claude_project_preserves_existing_global() {
1417        let tmp = TempDir::new().unwrap();
1418        let config_path = tmp.path().join("claude.json");
1419
1420        // Start with a global entry
1421        let existing = serde_json::json!({
1422            "mcpServers": {
1423                "sqry": { "command": "/usr/bin/sqry-mcp" }
1424            }
1425        });
1426        fs::write(
1427            &config_path,
1428            serde_json::to_string_pretty(&existing).unwrap(),
1429        )
1430        .unwrap();
1431
1432        // Add a project entry — should keep global
1433        let entry = serde_json::json!({
1434            "command": "/usr/bin/sqry-mcp",
1435            "env": { "SQRY_MCP_WORKSPACE_ROOT": "/my/project" }
1436        });
1437        write_claude_project_entry(&config_path, "/my/project", &entry, false, false).unwrap();
1438
1439        let content: Value =
1440            serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
1441        // Global entry still present
1442        assert_eq!(
1443            content["mcpServers"]["sqry"]["command"],
1444            "/usr/bin/sqry-mcp"
1445        );
1446        // Project entry also present
1447        assert_eq!(
1448            content["projects"]["/my/project"]["mcpServers"]["sqry"]["command"],
1449            "/usr/bin/sqry-mcp"
1450        );
1451    }
1452
1453    // -- Workspace root rejection for Codex/Gemini --
1454
1455    // -- Tool detection logic tests --
1456
1457    #[test]
1458    fn test_detect_tool_installed_unknown_tool() {
1459        // Unknown tool names should always return false
1460        assert!(!detect_tool_installed("unknown"));
1461        assert!(!detect_tool_installed(""));
1462        assert!(!detect_tool_installed("vscode"));
1463    }
1464
1465    // -- Status resilience: malformed config tolerance --
1466
1467    #[test]
1468    fn test_claude_status_json_malformed_config() {
1469        // claude_status_json() reads from ~/.claude.json which we can't easily
1470        // redirect, but we can verify the parse path by testing the underlying
1471        // serde_json::from_str behavior that the status functions rely on.
1472        let malformed = "not valid json {{{";
1473        let result: Result<Value, _> = serde_json::from_str(malformed);
1474        assert!(result.is_err());
1475
1476        // The status resilience pattern wraps this in unwrap_or_else:
1477        let fallback = result.map_or_else(
1478            |e| serde_json::json!({"configured": false, "error": format!("{e:#}")}),
1479            |_| serde_json::json!({"configured": true}),
1480        );
1481        assert_eq!(fallback["configured"], false);
1482        assert!(fallback["error"].as_str().unwrap().contains("expected"));
1483    }
1484
1485    #[test]
1486    fn test_codex_toml_parse_malformed() {
1487        // Verify that malformed TOML triggers an error that the status
1488        // resilience pattern would catch
1489        let malformed = "[invalid\nthis is not valid toml";
1490        let result: Result<toml_edit::DocumentMut, _> = malformed.parse();
1491        assert!(result.is_err());
1492    }
1493
1494    #[test]
1495    fn test_status_json_error_shape() {
1496        // Verify the error JSON shape matches what print_status_json produces
1497        let error_json =
1498            serde_json::json!({"configured": false, "error": "Failed to parse config"});
1499        assert_eq!(error_json["configured"], false);
1500        assert!(error_json["error"].is_string());
1501        // Verify it's valid JSON that a consumer could parse
1502        let serialized = serde_json::to_string(&error_json).unwrap();
1503        let _: Value = serde_json::from_str(&serialized).unwrap();
1504    }
1505
1506    // -- Workspace root rejection for Codex/Gemini --
1507
1508    #[test]
1509    fn test_workspace_root_rejected_for_codex() {
1510        let tmp = TempDir::new().unwrap();
1511        let root = tmp.path();
1512        fs::create_dir_all(root.join(".git")).unwrap();
1513
1514        let result = run_setup(
1515            &ToolTarget::Codex,
1516            &SetupScope::Auto,
1517            Some(root),
1518            false,
1519            true, // dry_run
1520            true,
1521        );
1522        assert!(result.is_err());
1523        assert!(
1524            result
1525                .unwrap_err()
1526                .to_string()
1527                .contains("Codex/Gemini use global configs")
1528        );
1529    }
1530
1531    #[test]
1532    fn test_workspace_root_rejected_for_gemini() {
1533        let tmp = TempDir::new().unwrap();
1534        let root = tmp.path();
1535        fs::create_dir_all(root.join(".git")).unwrap();
1536
1537        let result = run_setup(
1538            &ToolTarget::Gemini,
1539            &SetupScope::Auto,
1540            Some(root),
1541            false,
1542            true, // dry_run
1543            true,
1544        );
1545        assert!(result.is_err());
1546        assert!(
1547            result
1548                .unwrap_err()
1549                .to_string()
1550                .contains("Codex/Gemini use global configs")
1551        );
1552    }
1553}