Skip to main content

aft/
format.rs

1//! External tool runner and auto-formatter detection.
2//!
3//! Provides subprocess execution with timeout protection, language-to-formatter
4//! mapping, and the `auto_format` entry point used by `write_format_validate`.
5
6use std::collections::HashMap;
7use std::io::ErrorKind;
8use std::path::Path;
9use std::process::{Command, Stdio};
10use std::sync::Mutex;
11use std::thread;
12use std::time::{Duration, Instant};
13
14use crate::config::Config;
15use crate::parser::{detect_language, LangId};
16
17/// Result of running an external tool subprocess.
18#[derive(Debug)]
19pub struct ExternalToolResult {
20    pub stdout: String,
21    pub stderr: String,
22    pub exit_code: i32,
23}
24
25/// Errors from external tool execution.
26#[derive(Debug)]
27pub enum FormatError {
28    /// The tool binary was not found on PATH.
29    NotFound { tool: String },
30    /// The tool exceeded its timeout and was killed.
31    Timeout { tool: String, timeout_secs: u32 },
32    /// The tool exited with a non-zero status.
33    Failed { tool: String, stderr: String },
34    /// No formatter is configured for this language.
35    UnsupportedLanguage,
36}
37
38impl std::fmt::Display for FormatError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            FormatError::NotFound { tool } => write!(f, "formatter not found: {}", tool),
42            FormatError::Timeout { tool, timeout_secs } => {
43                write!(f, "formatter '{}' timed out after {}s", tool, timeout_secs)
44            }
45            FormatError::Failed { tool, stderr } => {
46                write!(f, "formatter '{}' failed: {}", tool, stderr)
47            }
48            FormatError::UnsupportedLanguage => write!(f, "unsupported language for formatting"),
49        }
50    }
51}
52
53/// Spawn a subprocess and wait for completion with timeout protection.
54///
55/// Polls `try_wait()` at 50ms intervals. On timeout, kills the child process
56/// and waits for it to exit. Returns `FormatError::NotFound` when the binary
57/// isn't on PATH.
58pub fn run_external_tool(
59    command: &str,
60    args: &[&str],
61    working_dir: Option<&Path>,
62    timeout_secs: u32,
63) -> Result<ExternalToolResult, FormatError> {
64    let mut cmd = Command::new(command);
65    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
66
67    if let Some(dir) = working_dir {
68        cmd.current_dir(dir);
69    }
70
71    let mut child = match cmd.spawn() {
72        Ok(c) => c,
73        Err(e) if e.kind() == ErrorKind::NotFound => {
74            return Err(FormatError::NotFound {
75                tool: command.to_string(),
76            });
77        }
78        Err(e) => {
79            return Err(FormatError::Failed {
80                tool: command.to_string(),
81                stderr: e.to_string(),
82            });
83        }
84    };
85
86    let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
87
88    loop {
89        match child.try_wait() {
90            Ok(Some(status)) => {
91                let stdout = child
92                    .stdout
93                    .take()
94                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
95                    .unwrap_or_default();
96                let stderr = child
97                    .stderr
98                    .take()
99                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
100                    .unwrap_or_default();
101
102                let exit_code = status.code().unwrap_or(-1);
103                if exit_code != 0 {
104                    return Err(FormatError::Failed {
105                        tool: command.to_string(),
106                        stderr,
107                    });
108                }
109
110                return Ok(ExternalToolResult {
111                    stdout,
112                    stderr,
113                    exit_code,
114                });
115            }
116            Ok(None) => {
117                // Still running
118                if Instant::now() >= deadline {
119                    // Kill the process and reap it
120                    let _ = child.kill();
121                    let _ = child.wait();
122                    return Err(FormatError::Timeout {
123                        tool: command.to_string(),
124                        timeout_secs,
125                    });
126                }
127                thread::sleep(Duration::from_millis(50));
128            }
129            Err(e) => {
130                return Err(FormatError::Failed {
131                    tool: command.to_string(),
132                    stderr: format!("try_wait error: {}", e),
133                });
134            }
135        }
136    }
137}
138
139/// TTL for tool availability cache entries.
140const TOOL_CACHE_TTL: Duration = Duration::from_secs(60);
141
142static TOOL_CACHE: std::sync::LazyLock<Mutex<HashMap<String, (bool, Instant)>>> =
143    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
144
145/// Check if a command exists by attempting to spawn it with `--version`.
146///
147/// First checks `<project_root>/node_modules/.bin/<command>` (for locally installed tools
148/// like biome, prettier), then falls back to PATH lookup.
149/// Results are cached for 60 seconds to avoid repeated subprocess spawning.
150fn tool_available(command: &str) -> bool {
151    if let Ok(cache) = TOOL_CACHE.lock() {
152        if let Some((available, checked_at)) = cache.get(command) {
153            if checked_at.elapsed() < TOOL_CACHE_TTL {
154                return *available;
155            }
156        }
157    }
158    let result = resolve_tool(command, None).is_some();
159    if let Ok(mut cache) = TOOL_CACHE.lock() {
160        cache.insert(command.to_string(), (result, Instant::now()));
161    }
162    result
163}
164
165/// Like `tool_available` but also checks node_modules/.bin relative to project_root.
166/// Returns the full path to the tool if found, otherwise None.
167fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
168    // 1. Check node_modules/.bin/<command> relative to project root
169    if let Some(root) = project_root {
170        let local_bin = root.join("node_modules").join(".bin").join(command);
171        if local_bin.exists() {
172            return Some(local_bin.to_string_lossy().to_string());
173        }
174    }
175
176    // 2. Fall back to PATH lookup
177    match Command::new(command)
178        .arg("--version")
179        .stdout(Stdio::null())
180        .stderr(Stdio::null())
181        .spawn()
182    {
183        Ok(mut child) => {
184            let _ = child.wait();
185            Some(command.to_string())
186        }
187        Err(_) => None,
188    }
189}
190
191/// Check if `ruff format` is available with a stable formatter.
192///
193/// Ruff's formatter became stable in v0.1.2. Versions before that output
194/// `NOT_YET_IMPLEMENTED_*` stubs instead of formatted code. We parse the
195/// version from `ruff --version` (format: "ruff X.Y.Z") and require >= 0.1.2.
196/// Falls back to false if ruff is not found or version cannot be parsed.
197fn ruff_format_available() -> bool {
198    let output = match Command::new("ruff")
199        .arg("--version")
200        .stdout(Stdio::piped())
201        .stderr(Stdio::null())
202        .output()
203    {
204        Ok(o) => o,
205        Err(_) => return false,
206    };
207
208    let version_str = String::from_utf8_lossy(&output.stdout);
209    // Parse "ruff X.Y.Z" or just "X.Y.Z"
210    let version_part = version_str
211        .trim()
212        .strip_prefix("ruff ")
213        .unwrap_or(version_str.trim());
214
215    let parts: Vec<&str> = version_part.split('.').collect();
216    if parts.len() < 3 {
217        return false;
218    }
219
220    let major: u32 = match parts[0].parse() {
221        Ok(v) => v,
222        Err(_) => return false,
223    };
224    let minor: u32 = match parts[1].parse() {
225        Ok(v) => v,
226        Err(_) => return false,
227    };
228    let patch: u32 = match parts[2].parse() {
229        Ok(v) => v,
230        Err(_) => return false,
231    };
232
233    // Require >= 0.1.2 where ruff format became stable
234    (major, minor, patch) >= (0, 1, 2)
235}
236
237/// Detect the appropriate formatter command and arguments for a file.
238///
239/// Priority per language:
240/// - TypeScript/JavaScript/TSX: `prettier --write <file>`
241/// - Python: `ruff format <file>` (fallback: `black <file>`)
242/// - Rust: `rustfmt <file>`
243/// - Go: `gofmt -w <file>`
244///
245/// Returns `None` if no formatter is available for the language.
246pub fn detect_formatter(
247    path: &Path,
248    lang: LangId,
249    config: &Config,
250) -> Option<(String, Vec<String>)> {
251    let file_str = path.to_string_lossy().to_string();
252
253    // 1. Per-language override from plugin config
254    let lang_key = match lang {
255        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
256        LangId::Python => "python",
257        LangId::Rust => "rust",
258        LangId::Go => "go",
259        LangId::C => "c",
260        LangId::Cpp => "cpp",
261        LangId::Zig => "zig",
262        LangId::CSharp => "csharp",
263        LangId::Markdown => "markdown",
264    };
265    let project_root = config.project_root.as_deref();
266    if let Some(preferred) = config.formatter.get(lang_key) {
267        return resolve_explicit_formatter(preferred, &file_str, lang, project_root);
268    }
269
270    // 2. Project config file detection only — no config file means no formatting.
271    //    This avoids silently reformatting code in projects without formatter setup.
272
273    match lang {
274        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
275            // biome.json / biome.jsonc → biome (check node_modules/.bin first)
276            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
277                if let Some(biome_cmd) = resolve_tool("biome", project_root) {
278                    return Some((
279                        biome_cmd,
280                        vec!["format".to_string(), "--write".to_string(), file_str],
281                    ));
282                }
283            }
284            // .prettierrc / .prettierrc.* / prettier.config.* → prettier (check node_modules/.bin first)
285            if has_project_config(
286                project_root,
287                &[
288                    ".prettierrc",
289                    ".prettierrc.json",
290                    ".prettierrc.yml",
291                    ".prettierrc.yaml",
292                    ".prettierrc.js",
293                    ".prettierrc.cjs",
294                    ".prettierrc.mjs",
295                    ".prettierrc.toml",
296                    "prettier.config.js",
297                    "prettier.config.cjs",
298                    "prettier.config.mjs",
299                ],
300            ) {
301                if let Some(prettier_cmd) = resolve_tool("prettier", project_root) {
302                    return Some((prettier_cmd, vec!["--write".to_string(), file_str]));
303                }
304            }
305            // deno.json / deno.jsonc → deno fmt
306            if has_project_config(project_root, &["deno.json", "deno.jsonc"])
307                && tool_available("deno")
308            {
309                return Some(("deno".to_string(), vec!["fmt".to_string(), file_str]));
310            }
311            // No config file found → do not format
312            None
313        }
314        LangId::Python => {
315            // ruff.toml or pyproject.toml with ruff config → ruff
316            if (has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
317                || has_pyproject_tool(project_root, "ruff"))
318                && ruff_format_available()
319            {
320                return Some(("ruff".to_string(), vec!["format".to_string(), file_str]));
321            }
322            // pyproject.toml with black config → black
323            if has_pyproject_tool(project_root, "black") && tool_available("black") {
324                return Some(("black".to_string(), vec![file_str]));
325            }
326            // No config file found → do not format
327            None
328        }
329        LangId::Rust => {
330            // Cargo.toml implies standard Rust formatting
331            if has_project_config(project_root, &["Cargo.toml"]) && tool_available("rustfmt") {
332                Some(("rustfmt".to_string(), vec![file_str]))
333            } else {
334                None
335            }
336        }
337        LangId::Go => {
338            // go.mod implies a Go project
339            if has_project_config(project_root, &["go.mod"]) {
340                if tool_available("goimports") {
341                    Some(("goimports".to_string(), vec!["-w".to_string(), file_str]))
342                } else if tool_available("gofmt") {
343                    Some(("gofmt".to_string(), vec!["-w".to_string(), file_str]))
344                } else {
345                    None
346                }
347            } else {
348                None
349            }
350        }
351        LangId::C | LangId::Cpp | LangId::Zig | LangId::CSharp => None,
352        LangId::Markdown => None,
353    }
354}
355
356/// Resolve an explicitly configured formatter name to a command + args.
357/// Uses resolve_tool() to find the binary in node_modules/.bin or PATH,
358/// so locally-installed tools (biome, prettier) are found even when not on PATH.
359fn resolve_explicit_formatter(
360    name: &str,
361    file_str: &str,
362    lang: LangId,
363    project_root: Option<&Path>,
364) -> Option<(String, Vec<String>)> {
365    let cmd = match name {
366        "none" | "off" | "false" => return None,
367        "biome" | "prettier" | "deno" | "ruff" | "black" | "rustfmt" | "goimports" | "gofmt" => {
368            // Resolve through node_modules/.bin first, then PATH
369            match resolve_tool(name, project_root) {
370                Some(resolved) => resolved,
371                None => {
372                    log::warn!(
373                        "[aft] format: configured formatter '{}' not found in node_modules/.bin or PATH",
374                        name
375                    );
376                    return None;
377                }
378            }
379        }
380        _ => {
381            log::debug!(
382                "[aft] format: unknown preferred_formatter '{}' for {:?}, falling back to auto",
383                name,
384                lang
385            );
386            return None;
387        }
388    };
389
390    let args = match name {
391        "biome" => vec![
392            "format".to_string(),
393            "--write".to_string(),
394            file_str.to_string(),
395        ],
396        "prettier" => vec!["--write".to_string(), file_str.to_string()],
397        "deno" => vec!["fmt".to_string(), file_str.to_string()],
398        "ruff" => vec!["format".to_string(), file_str.to_string()],
399        "black" => vec![file_str.to_string()],
400        "rustfmt" => vec![file_str.to_string()],
401        "goimports" => vec!["-w".to_string(), file_str.to_string()],
402        "gofmt" => vec!["-w".to_string(), file_str.to_string()],
403        _ => unreachable!(), // Already handled above
404    };
405
406    Some((cmd, args))
407}
408
409/// Check if any of the given config file names exist in the project root.
410fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
411    let root = match project_root {
412        Some(r) => r,
413        None => return false,
414    };
415    filenames.iter().any(|f| root.join(f).exists())
416}
417
418/// Check if pyproject.toml exists and contains a `[tool.<name>]` section.
419fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
420    let root = match project_root {
421        Some(r) => r,
422        None => return false,
423    };
424    let pyproject = root.join("pyproject.toml");
425    if !pyproject.exists() {
426        return false;
427    }
428    match std::fs::read_to_string(&pyproject) {
429        Ok(content) => {
430            let pattern = format!("[tool.{}]", tool_name);
431            content.contains(&pattern)
432        }
433        Err(_) => false,
434    }
435}
436
437/// Auto-format a file using the detected formatter for its language.
438///
439/// Returns `(formatted, skip_reason)`:
440/// - `(true, None)` — file was successfully formatted
441/// - `(false, Some(reason))` — formatting was skipped, reason explains why
442///
443/// Skip reasons: `"unsupported_language"`, `"not_found"`, `"timeout"`, `"error"`
444pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
445    // Check if formatting is disabled via plugin config
446    if !config.format_on_edit {
447        return (false, Some("disabled".to_string()));
448    }
449
450    let lang = match detect_language(path) {
451        Some(l) => l,
452        None => {
453            log::debug!(
454                "[aft] format: {} (skipped: unsupported_language)",
455                path.display()
456            );
457            return (false, Some("unsupported_language".to_string()));
458        }
459    };
460
461    let (cmd, args) = match detect_formatter(path, lang, config) {
462        Some(pair) => pair,
463        None => {
464            log::warn!("format: {} (skipped: not_found)", path.display());
465            return (false, Some("not_found".to_string()));
466        }
467    };
468
469    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
470
471    match run_external_tool(&cmd, &arg_refs, None, config.formatter_timeout_secs) {
472        Ok(_) => {
473            log::info!("format: {} ({})", path.display(), cmd);
474            (true, None)
475        }
476        Err(FormatError::Timeout { .. }) => {
477            log::warn!("format: {} (skipped: timeout)", path.display());
478            (false, Some("timeout".to_string()))
479        }
480        Err(FormatError::NotFound { .. }) => {
481            log::warn!("format: {} (skipped: not_found)", path.display());
482            (false, Some("not_found".to_string()))
483        }
484        Err(FormatError::Failed { stderr, .. }) => {
485            log::debug!(
486                "[aft] format: {} (skipped: error: {})",
487                path.display(),
488                stderr.lines().next().unwrap_or("unknown")
489            );
490            (false, Some("error".to_string()))
491        }
492        Err(FormatError::UnsupportedLanguage) => {
493            log::debug!(
494                "[aft] format: {} (skipped: unsupported_language)",
495                path.display()
496            );
497            (false, Some("unsupported_language".to_string()))
498        }
499    }
500}
501
502/// Spawn a subprocess and capture output regardless of exit code.
503///
504/// Unlike `run_external_tool`, this does NOT treat non-zero exit as an error —
505/// type checkers return non-zero when they find issues, which is expected.
506/// Returns `FormatError::NotFound` when the binary isn't on PATH, and
507/// `FormatError::Timeout` if the deadline is exceeded.
508pub fn run_external_tool_capture(
509    command: &str,
510    args: &[&str],
511    working_dir: Option<&Path>,
512    timeout_secs: u32,
513) -> Result<ExternalToolResult, FormatError> {
514    let mut cmd = Command::new(command);
515    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
516
517    if let Some(dir) = working_dir {
518        cmd.current_dir(dir);
519    }
520
521    let mut child = match cmd.spawn() {
522        Ok(c) => c,
523        Err(e) if e.kind() == ErrorKind::NotFound => {
524            return Err(FormatError::NotFound {
525                tool: command.to_string(),
526            });
527        }
528        Err(e) => {
529            return Err(FormatError::Failed {
530                tool: command.to_string(),
531                stderr: e.to_string(),
532            });
533        }
534    };
535
536    let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
537
538    loop {
539        match child.try_wait() {
540            Ok(Some(status)) => {
541                let stdout = child
542                    .stdout
543                    .take()
544                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
545                    .unwrap_or_default();
546                let stderr = child
547                    .stderr
548                    .take()
549                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
550                    .unwrap_or_default();
551
552                return Ok(ExternalToolResult {
553                    stdout,
554                    stderr,
555                    exit_code: status.code().unwrap_or(-1),
556                });
557            }
558            Ok(None) => {
559                if Instant::now() >= deadline {
560                    let _ = child.kill();
561                    let _ = child.wait();
562                    return Err(FormatError::Timeout {
563                        tool: command.to_string(),
564                        timeout_secs,
565                    });
566                }
567                thread::sleep(Duration::from_millis(50));
568            }
569            Err(e) => {
570                return Err(FormatError::Failed {
571                    tool: command.to_string(),
572                    stderr: format!("try_wait error: {}", e),
573                });
574            }
575        }
576    }
577}
578
579// ============================================================================
580// Type-checker validation (R017)
581// ============================================================================
582
583/// A structured error from a type checker.
584#[derive(Debug, Clone, serde::Serialize)]
585pub struct ValidationError {
586    pub line: u32,
587    pub column: u32,
588    pub message: String,
589    pub severity: String,
590}
591
592/// Detect the appropriate type checker command and arguments for a file.
593///
594/// Returns `(command, args)` for the type checker. The `--noEmit` / equivalent
595/// flags ensure no output files are produced.
596///
597/// Supported:
598/// - TypeScript/JavaScript/TSX → `npx tsc --noEmit` (fallback: `tsc --noEmit`)
599/// - Python → `pyright`
600/// - Rust → `cargo check`
601/// - Go → `go vet`
602pub fn detect_type_checker(
603    path: &Path,
604    lang: LangId,
605    config: &Config,
606) -> Option<(String, Vec<String>)> {
607    let file_str = path.to_string_lossy().to_string();
608    let project_root = config.project_root.as_deref();
609
610    // Per-language override from plugin config
611    let lang_key = match lang {
612        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
613        LangId::Python => "python",
614        LangId::Rust => "rust",
615        LangId::Go => "go",
616        LangId::C => "c",
617        LangId::Cpp => "cpp",
618        LangId::Zig => "zig",
619        LangId::CSharp => "csharp",
620        LangId::Markdown => "markdown",
621    };
622    if let Some(preferred) = config.checker.get(lang_key) {
623        return resolve_explicit_checker(preferred, &file_str, lang, project_root);
624    }
625
626    match lang {
627        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
628            // biome.json → biome check (lint + type errors, check node_modules/.bin first)
629            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
630                if let Some(biome_cmd) = resolve_tool("biome", project_root) {
631                    return Some((biome_cmd, vec!["check".to_string(), file_str]));
632                }
633            }
634            // tsconfig.json → tsc (check node_modules/.bin first)
635            if has_project_config(project_root, &["tsconfig.json"]) {
636                if let Some(tsc_cmd) = resolve_tool("tsc", project_root) {
637                    return Some((
638                        tsc_cmd,
639                        vec![
640                            "--noEmit".to_string(),
641                            "--pretty".to_string(),
642                            "false".to_string(),
643                        ],
644                    ));
645                } else if tool_available("npx") {
646                    return Some((
647                        "npx".to_string(),
648                        vec![
649                            "tsc".to_string(),
650                            "--noEmit".to_string(),
651                            "--pretty".to_string(),
652                            "false".to_string(),
653                        ],
654                    ));
655                }
656            }
657            None
658        }
659        LangId::Python => {
660            // pyrightconfig.json or pyproject.toml with pyright → pyright
661            if has_project_config(project_root, &["pyrightconfig.json"])
662                || has_pyproject_tool(project_root, "pyright")
663            {
664                if let Some(pyright_cmd) = resolve_tool("pyright", project_root) {
665                    return Some((pyright_cmd, vec!["--outputjson".to_string(), file_str]));
666                }
667            }
668            // ruff.toml or pyproject.toml with ruff → ruff check
669            if (has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
670                || has_pyproject_tool(project_root, "ruff"))
671                && ruff_format_available()
672            {
673                return Some((
674                    "ruff".to_string(),
675                    vec![
676                        "check".to_string(),
677                        "--output-format=json".to_string(),
678                        file_str,
679                    ],
680                ));
681            }
682            None
683        }
684        LangId::Rust => {
685            // Cargo.toml implies cargo check
686            if has_project_config(project_root, &["Cargo.toml"]) && tool_available("cargo") {
687                Some((
688                    "cargo".to_string(),
689                    vec!["check".to_string(), "--message-format=json".to_string()],
690                ))
691            } else {
692                None
693            }
694        }
695        LangId::Go => {
696            // go.mod implies Go project
697            if has_project_config(project_root, &["go.mod"]) {
698                if tool_available("staticcheck") {
699                    Some(("staticcheck".to_string(), vec![file_str]))
700                } else if tool_available("go") {
701                    Some(("go".to_string(), vec!["vet".to_string(), file_str]))
702                } else {
703                    None
704                }
705            } else {
706                None
707            }
708        }
709        LangId::C | LangId::Cpp | LangId::Zig | LangId::CSharp => None,
710        LangId::Markdown => None,
711    }
712}
713
714/// Resolve an explicitly configured checker name to a command + args.
715/// Uses resolve_tool() to find the binary in node_modules/.bin or PATH,
716/// so locally-installed tools (biome, tsc, pyright) are found even when not on PATH.
717fn resolve_explicit_checker(
718    name: &str,
719    file_str: &str,
720    _lang: LangId,
721    project_root: Option<&Path>,
722) -> Option<(String, Vec<String>)> {
723    match name {
724        "none" | "off" | "false" => return None,
725        _ => {}
726    }
727
728    // tsc is special — always runs via npx
729    if name == "tsc" {
730        return Some((
731            "npx".to_string(),
732            vec![
733                "tsc".to_string(),
734                "--noEmit".to_string(),
735                "--pretty".to_string(),
736                "false".to_string(),
737            ],
738        ));
739    }
740    // cargo and go are system tools, not in node_modules
741    if name == "cargo" {
742        return Some((
743            "cargo".to_string(),
744            vec!["check".to_string(), "--message-format=json".to_string()],
745        ));
746    }
747    if name == "go" {
748        return Some((
749            "go".to_string(),
750            vec!["vet".to_string(), file_str.to_string()],
751        ));
752    }
753
754    // For node-ecosystem tools, resolve through node_modules/.bin first
755    let known_tools = ["biome", "pyright", "ruff", "staticcheck"];
756    if known_tools.contains(&name) {
757        let cmd = match resolve_tool(name, project_root) {
758            Some(resolved) => resolved,
759            None => {
760                log::warn!(
761                    "[aft] validate: configured checker '{}' not found in node_modules/.bin or PATH",
762                    name
763                );
764                return None;
765            }
766        };
767
768        let args = match name {
769            "biome" => vec!["check".to_string(), file_str.to_string()],
770            "pyright" => vec!["--outputjson".to_string(), file_str.to_string()],
771            "ruff" => vec![
772                "check".to_string(),
773                "--output-format=json".to_string(),
774                file_str.to_string(),
775            ],
776            "staticcheck" => vec![file_str.to_string()],
777            _ => unreachable!(),
778        };
779
780        return Some((cmd, args));
781    }
782
783    log::debug!(
784        "[aft] validate: unknown preferred_checker '{}', falling back to auto",
785        name
786    );
787    None
788}
789
790/// Parse type checker output into structured validation errors.
791///
792/// Handles output formats from tsc, pyright (JSON), cargo check (JSON), and go vet.
793/// Filters to errors related to the edited file where feasible.
794pub fn parse_checker_output(
795    stdout: &str,
796    stderr: &str,
797    file: &Path,
798    checker: &str,
799) -> Vec<ValidationError> {
800    match checker {
801        "npx" | "tsc" => parse_tsc_output(stdout, stderr, file),
802        "pyright" => parse_pyright_output(stdout, file),
803        "cargo" => parse_cargo_output(stdout, stderr, file),
804        "go" => parse_go_vet_output(stderr, file),
805        _ => Vec::new(),
806    }
807}
808
809/// Parse tsc output lines like: `path(line,col): error TSxxxx: message`
810fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
811    let mut errors = Vec::new();
812    let file_str = file.to_string_lossy();
813    // tsc writes diagnostics to stdout (with --pretty false)
814    let combined = format!("{}{}", stdout, stderr);
815    for line in combined.lines() {
816        // Format: path(line,col): severity TSxxxx: message
817        // or: path(line,col): severity: message
818        if let Some((loc, rest)) = line.split_once("): ") {
819            // Check if this error is for our file (compare filename part)
820            let file_part = loc.split('(').next().unwrap_or("");
821            if !file_str.ends_with(file_part)
822                && !file_part.ends_with(&*file_str)
823                && file_part != &*file_str
824            {
825                continue;
826            }
827
828            // Parse (line,col) from the location part
829            let coords = loc.split('(').last().unwrap_or("");
830            let parts: Vec<&str> = coords.split(',').collect();
831            let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
832            let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
833
834            // Parse severity and message
835            let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
836                ("error".to_string(), msg.to_string())
837            } else if let Some(msg) = rest.strip_prefix("warning ") {
838                ("warning".to_string(), msg.to_string())
839            } else {
840                ("error".to_string(), rest.to_string())
841            };
842
843            errors.push(ValidationError {
844                line: line_num,
845                column: col_num,
846                message,
847                severity,
848            });
849        }
850    }
851    errors
852}
853
854/// Parse pyright JSON output.
855fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
856    let mut errors = Vec::new();
857    let file_str = file.to_string_lossy();
858
859    // pyright --outputjson emits JSON with generalDiagnostics array
860    if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
861        if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
862            for diag in diags {
863                // Filter to our file
864                let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
865                if !diag_file.is_empty()
866                    && !file_str.ends_with(diag_file)
867                    && !diag_file.ends_with(&*file_str)
868                    && diag_file != &*file_str
869                {
870                    continue;
871                }
872
873                let line_num = diag
874                    .get("range")
875                    .and_then(|r| r.get("start"))
876                    .and_then(|s| s.get("line"))
877                    .and_then(|l| l.as_u64())
878                    .unwrap_or(0) as u32;
879                let col_num = diag
880                    .get("range")
881                    .and_then(|r| r.get("start"))
882                    .and_then(|s| s.get("character"))
883                    .and_then(|c| c.as_u64())
884                    .unwrap_or(0) as u32;
885                let message = diag
886                    .get("message")
887                    .and_then(|m| m.as_str())
888                    .unwrap_or("unknown error")
889                    .to_string();
890                let severity = diag
891                    .get("severity")
892                    .and_then(|s| s.as_str())
893                    .unwrap_or("error")
894                    .to_lowercase();
895
896                errors.push(ValidationError {
897                    line: line_num + 1, // pyright uses 0-indexed lines
898                    column: col_num,
899                    message,
900                    severity,
901                });
902            }
903        }
904    }
905    errors
906}
907
908/// Parse cargo check JSON output, filtering to errors in the target file.
909fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
910    let mut errors = Vec::new();
911    let file_str = file.to_string_lossy();
912
913    for line in stdout.lines() {
914        if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
915            if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
916                continue;
917            }
918            let message_obj = match msg.get("message") {
919                Some(m) => m,
920                None => continue,
921            };
922
923            let level = message_obj
924                .get("level")
925                .and_then(|l| l.as_str())
926                .unwrap_or("error");
927
928            // Only include errors and warnings, skip notes/help
929            if level != "error" && level != "warning" {
930                continue;
931            }
932
933            let text = message_obj
934                .get("message")
935                .and_then(|m| m.as_str())
936                .unwrap_or("unknown error")
937                .to_string();
938
939            // Find the primary span for our file
940            if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
941                for span in spans {
942                    let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
943                    let is_primary = span
944                        .get("is_primary")
945                        .and_then(|p| p.as_bool())
946                        .unwrap_or(false);
947
948                    if !is_primary {
949                        continue;
950                    }
951
952                    // Filter to our file
953                    if !file_str.ends_with(span_file)
954                        && !span_file.ends_with(&*file_str)
955                        && span_file != &*file_str
956                    {
957                        continue;
958                    }
959
960                    let line_num =
961                        span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
962                    let col_num = span
963                        .get("column_start")
964                        .and_then(|c| c.as_u64())
965                        .unwrap_or(0) as u32;
966
967                    errors.push(ValidationError {
968                        line: line_num,
969                        column: col_num,
970                        message: text.clone(),
971                        severity: level.to_string(),
972                    });
973                }
974            }
975        }
976    }
977    errors
978}
979
980/// Parse go vet output lines like: `path:line:col: message`
981fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
982    let mut errors = Vec::new();
983    let file_str = file.to_string_lossy();
984
985    for line in stderr.lines() {
986        // Format: path:line:col: message  OR  path:line: message
987        let parts: Vec<&str> = line.splitn(4, ':').collect();
988        if parts.len() < 3 {
989            continue;
990        }
991
992        let err_file = parts[0].trim();
993        if !file_str.ends_with(err_file)
994            && !err_file.ends_with(&*file_str)
995            && err_file != &*file_str
996        {
997            continue;
998        }
999
1000        let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
1001        let (col_num, message) = if parts.len() >= 4 {
1002            if let Ok(col) = parts[2].trim().parse::<u32>() {
1003                (col, parts[3].trim().to_string())
1004            } else {
1005                // parts[2] is part of the message, not a column
1006                (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
1007            }
1008        } else {
1009            (0, parts[2].trim().to_string())
1010        };
1011
1012        errors.push(ValidationError {
1013            line: line_num,
1014            column: col_num,
1015            message,
1016            severity: "error".to_string(),
1017        });
1018    }
1019    errors
1020}
1021
1022/// Run the project's type checker and return structured validation errors.
1023///
1024/// Returns `(errors, skip_reason)`:
1025/// - `(errors, None)` — checker ran, errors may be empty (= valid code)
1026/// - `([], Some(reason))` — checker was skipped
1027///
1028/// Skip reasons: `"unsupported_language"`, `"not_found"`, `"timeout"`, `"error"`
1029pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
1030    let lang = match detect_language(path) {
1031        Some(l) => l,
1032        None => {
1033            log::debug!(
1034                "[aft] validate: {} (skipped: unsupported_language)",
1035                path.display()
1036            );
1037            return (Vec::new(), Some("unsupported_language".to_string()));
1038        }
1039    };
1040
1041    let (cmd, args) = match detect_type_checker(path, lang, config) {
1042        Some(pair) => pair,
1043        None => {
1044            log::warn!("validate: {} (skipped: not_found)", path.display());
1045            return (Vec::new(), Some("not_found".to_string()));
1046        }
1047    };
1048
1049    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1050
1051    // Type checkers may need to run from the project root
1052    let working_dir = path.parent();
1053
1054    match run_external_tool_capture(
1055        &cmd,
1056        &arg_refs,
1057        working_dir,
1058        config.type_checker_timeout_secs,
1059    ) {
1060        Ok(result) => {
1061            let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
1062            log::debug!(
1063                "[aft] validate: {} ({}, {} errors)",
1064                path.display(),
1065                cmd,
1066                errors.len()
1067            );
1068            (errors, None)
1069        }
1070        Err(FormatError::Timeout { .. }) => {
1071            log::error!("validate: {} (skipped: timeout)", path.display());
1072            (Vec::new(), Some("timeout".to_string()))
1073        }
1074        Err(FormatError::NotFound { .. }) => {
1075            log::warn!("validate: {} (skipped: not_found)", path.display());
1076            (Vec::new(), Some("not_found".to_string()))
1077        }
1078        Err(FormatError::Failed { stderr, .. }) => {
1079            log::debug!(
1080                "[aft] validate: {} (skipped: error: {})",
1081                path.display(),
1082                stderr.lines().next().unwrap_or("unknown")
1083            );
1084            (Vec::new(), Some("error".to_string()))
1085        }
1086        Err(FormatError::UnsupportedLanguage) => {
1087            log::debug!(
1088                "[aft] validate: {} (skipped: unsupported_language)",
1089                path.display()
1090            );
1091            (Vec::new(), Some("unsupported_language".to_string()))
1092        }
1093    }
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098    use super::*;
1099    use std::fs;
1100    use std::io::Write;
1101
1102    #[test]
1103    fn run_external_tool_not_found() {
1104        let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1105        assert!(result.is_err());
1106        match result.unwrap_err() {
1107            FormatError::NotFound { tool } => {
1108                assert_eq!(tool, "__nonexistent_tool_xyz__");
1109            }
1110            other => panic!("expected NotFound, got: {:?}", other),
1111        }
1112    }
1113
1114    #[test]
1115    fn run_external_tool_timeout_kills_subprocess() {
1116        // Use `sleep 60` as a long-running process, timeout after 1 second
1117        let result = run_external_tool("sleep", &["60"], None, 1);
1118        assert!(result.is_err());
1119        match result.unwrap_err() {
1120            FormatError::Timeout { tool, timeout_secs } => {
1121                assert_eq!(tool, "sleep");
1122                assert_eq!(timeout_secs, 1);
1123            }
1124            other => panic!("expected Timeout, got: {:?}", other),
1125        }
1126    }
1127
1128    #[test]
1129    fn run_external_tool_success() {
1130        let result = run_external_tool("echo", &["hello"], None, 5);
1131        assert!(result.is_ok());
1132        let res = result.unwrap();
1133        assert_eq!(res.exit_code, 0);
1134        assert!(res.stdout.contains("hello"));
1135    }
1136
1137    #[test]
1138    fn run_external_tool_nonzero_exit() {
1139        // `false` always exits with code 1
1140        let result = run_external_tool("false", &[], None, 5);
1141        assert!(result.is_err());
1142        match result.unwrap_err() {
1143            FormatError::Failed { tool, .. } => {
1144                assert_eq!(tool, "false");
1145            }
1146            other => panic!("expected Failed, got: {:?}", other),
1147        }
1148    }
1149
1150    #[test]
1151    fn auto_format_unsupported_language() {
1152        let dir = tempfile::tempdir().unwrap();
1153        let path = dir.path().join("file.txt");
1154        fs::write(&path, "hello").unwrap();
1155
1156        let config = Config::default();
1157        let (formatted, reason) = auto_format(&path, &config);
1158        assert!(!formatted);
1159        assert_eq!(reason.as_deref(), Some("unsupported_language"));
1160    }
1161
1162    #[test]
1163    fn detect_formatter_rust_when_rustfmt_available() {
1164        let dir = tempfile::tempdir().unwrap();
1165        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1166        let path = dir.path().join("test.rs");
1167        let config = Config {
1168            project_root: Some(dir.path().to_path_buf()),
1169            ..Config::default()
1170        };
1171        let result = detect_formatter(&path, LangId::Rust, &config);
1172        if tool_available("rustfmt") {
1173            let (cmd, args) = result.unwrap();
1174            assert_eq!(cmd, "rustfmt");
1175            assert!(args.iter().any(|a| a.ends_with("test.rs")));
1176        } else {
1177            assert!(result.is_none());
1178        }
1179    }
1180
1181    #[test]
1182    fn detect_formatter_go_mapping() {
1183        let dir = tempfile::tempdir().unwrap();
1184        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1185        let path = dir.path().join("main.go");
1186        let config = Config {
1187            project_root: Some(dir.path().to_path_buf()),
1188            ..Config::default()
1189        };
1190        let result = detect_formatter(&path, LangId::Go, &config);
1191        if tool_available("goimports") {
1192            let (cmd, args) = result.unwrap();
1193            assert_eq!(cmd, "goimports");
1194            assert!(args.contains(&"-w".to_string()));
1195        } else if tool_available("gofmt") {
1196            let (cmd, args) = result.unwrap();
1197            assert_eq!(cmd, "gofmt");
1198            assert!(args.contains(&"-w".to_string()));
1199        } else {
1200            assert!(result.is_none());
1201        }
1202    }
1203
1204    #[test]
1205    fn detect_formatter_python_mapping() {
1206        let dir = tempfile::tempdir().unwrap();
1207        fs::write(dir.path().join("ruff.toml"), "").unwrap();
1208        let path = dir.path().join("main.py");
1209        let config = Config {
1210            project_root: Some(dir.path().to_path_buf()),
1211            ..Config::default()
1212        };
1213        let result = detect_formatter(&path, LangId::Python, &config);
1214        if ruff_format_available() {
1215            let (cmd, args) = result.unwrap();
1216            assert_eq!(cmd, "ruff");
1217            assert!(args.contains(&"format".to_string()));
1218        } else {
1219            assert!(result.is_none());
1220        }
1221    }
1222
1223    #[test]
1224    fn detect_formatter_no_config_returns_none() {
1225        let path = Path::new("test.ts");
1226        let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1227        assert!(
1228            result.is_none(),
1229            "expected no formatter without project config"
1230        );
1231    }
1232
1233    #[test]
1234    fn detect_formatter_explicit_override() {
1235        // Create a temp dir with a fake node_modules/.bin/biome so resolve_tool finds it
1236        let dir = tempfile::tempdir().unwrap();
1237        let bin_dir = dir.path().join("node_modules").join(".bin");
1238        fs::create_dir_all(&bin_dir).unwrap();
1239        #[cfg(unix)]
1240        {
1241            use std::os::unix::fs::PermissionsExt;
1242            let fake = bin_dir.join("biome");
1243            fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
1244            fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
1245        }
1246        #[cfg(not(unix))]
1247        {
1248            fs::write(bin_dir.join("biome.cmd"), "@echo 1.0.0").unwrap();
1249        }
1250
1251        let path = Path::new("test.ts");
1252        let mut config = Config::default();
1253        config.project_root = Some(dir.path().to_path_buf());
1254        config
1255            .formatter
1256            .insert("typescript".to_string(), "biome".to_string());
1257        let result = detect_formatter(path, LangId::TypeScript, &config);
1258        let (cmd, args) = result.unwrap();
1259        assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
1260        assert!(args.contains(&"format".to_string()));
1261        assert!(args.contains(&"--write".to_string()));
1262    }
1263
1264    #[test]
1265    fn auto_format_happy_path_rustfmt() {
1266        if !tool_available("rustfmt") {
1267            log::warn!("skipping: rustfmt not available");
1268            return;
1269        }
1270
1271        let dir = tempfile::tempdir().unwrap();
1272        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1273        let path = dir.path().join("test.rs");
1274
1275        let mut f = fs::File::create(&path).unwrap();
1276        writeln!(f, "fn    main()   {{  println!(\"hello\");  }}").unwrap();
1277        drop(f);
1278
1279        let config = Config {
1280            project_root: Some(dir.path().to_path_buf()),
1281            ..Config::default()
1282        };
1283        let (formatted, reason) = auto_format(&path, &config);
1284        assert!(formatted, "expected formatting to succeed");
1285        assert!(reason.is_none());
1286
1287        let content = fs::read_to_string(&path).unwrap();
1288        assert!(
1289            !content.contains("fn    main"),
1290            "expected rustfmt to fix spacing"
1291        );
1292    }
1293
1294    #[test]
1295    fn parse_tsc_output_basic() {
1296        let stdout = "src/app.ts(10,5): error TS2322: Type 'string' is not assignable to type 'number'.\nsrc/app.ts(20,1): error TS2304: Cannot find name 'foo'.\n";
1297        let file = Path::new("src/app.ts");
1298        let errors = parse_tsc_output(stdout, "", file);
1299        assert_eq!(errors.len(), 2);
1300        assert_eq!(errors[0].line, 10);
1301        assert_eq!(errors[0].column, 5);
1302        assert_eq!(errors[0].severity, "error");
1303        assert!(errors[0].message.contains("TS2322"));
1304        assert_eq!(errors[1].line, 20);
1305    }
1306
1307    #[test]
1308    fn parse_tsc_output_filters_other_files() {
1309        let stdout =
1310            "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
1311        let file = Path::new("src/app.ts");
1312        let errors = parse_tsc_output(stdout, "", file);
1313        assert_eq!(errors.len(), 1);
1314        assert_eq!(errors[0].line, 5);
1315    }
1316
1317    #[test]
1318    fn parse_cargo_output_basic() {
1319        let json_line = r#"{"reason":"compiler-message","message":{"level":"error","message":"mismatched types","spans":[{"file_name":"src/main.rs","line_start":10,"column_start":5,"is_primary":true}]}}"#;
1320        let file = Path::new("src/main.rs");
1321        let errors = parse_cargo_output(json_line, "", file);
1322        assert_eq!(errors.len(), 1);
1323        assert_eq!(errors[0].line, 10);
1324        assert_eq!(errors[0].column, 5);
1325        assert_eq!(errors[0].severity, "error");
1326        assert!(errors[0].message.contains("mismatched types"));
1327    }
1328
1329    #[test]
1330    fn parse_cargo_output_skips_notes() {
1331        // Notes and help messages should be filtered out
1332        let json_line = r#"{"reason":"compiler-message","message":{"level":"note","message":"expected this","spans":[{"file_name":"src/main.rs","line_start":10,"column_start":5,"is_primary":true}]}}"#;
1333        let file = Path::new("src/main.rs");
1334        let errors = parse_cargo_output(json_line, "", file);
1335        assert_eq!(errors.len(), 0);
1336    }
1337
1338    #[test]
1339    fn parse_cargo_output_filters_other_files() {
1340        let json_line = r#"{"reason":"compiler-message","message":{"level":"error","message":"err","spans":[{"file_name":"src/other.rs","line_start":1,"column_start":1,"is_primary":true}]}}"#;
1341        let file = Path::new("src/main.rs");
1342        let errors = parse_cargo_output(json_line, "", file);
1343        assert_eq!(errors.len(), 0);
1344    }
1345
1346    #[test]
1347    fn parse_go_vet_output_basic() {
1348        let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
1349        let file = Path::new("main.go");
1350        let errors = parse_go_vet_output(stderr, file);
1351        assert_eq!(errors.len(), 2);
1352        assert_eq!(errors[0].line, 10);
1353        assert_eq!(errors[0].column, 5);
1354        assert!(errors[0].message.contains("unreachable code"));
1355        assert_eq!(errors[1].line, 20);
1356        assert_eq!(errors[1].column, 0);
1357    }
1358
1359    #[test]
1360    fn parse_pyright_output_basic() {
1361        let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
1362        let file = Path::new("test.py");
1363        let errors = parse_pyright_output(stdout, file);
1364        assert_eq!(errors.len(), 1);
1365        assert_eq!(errors[0].line, 5); // 0-indexed → 1-indexed
1366        assert_eq!(errors[0].column, 10);
1367        assert_eq!(errors[0].severity, "error");
1368        assert!(errors[0].message.contains("Type error here"));
1369    }
1370
1371    #[test]
1372    fn validate_full_unsupported_language() {
1373        let dir = tempfile::tempdir().unwrap();
1374        let path = dir.path().join("file.txt");
1375        fs::write(&path, "hello").unwrap();
1376
1377        let config = Config::default();
1378        let (errors, reason) = validate_full(&path, &config);
1379        assert!(errors.is_empty());
1380        assert_eq!(reason.as_deref(), Some("unsupported_language"));
1381    }
1382
1383    #[test]
1384    fn detect_type_checker_rust() {
1385        let dir = tempfile::tempdir().unwrap();
1386        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1387        let path = dir.path().join("src/main.rs");
1388        let config = Config {
1389            project_root: Some(dir.path().to_path_buf()),
1390            ..Config::default()
1391        };
1392        let result = detect_type_checker(&path, LangId::Rust, &config);
1393        if tool_available("cargo") {
1394            let (cmd, args) = result.unwrap();
1395            assert_eq!(cmd, "cargo");
1396            assert!(args.contains(&"check".to_string()));
1397        } else {
1398            assert!(result.is_none());
1399        }
1400    }
1401
1402    #[test]
1403    fn detect_type_checker_go() {
1404        let dir = tempfile::tempdir().unwrap();
1405        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1406        let path = dir.path().join("main.go");
1407        let config = Config {
1408            project_root: Some(dir.path().to_path_buf()),
1409            ..Config::default()
1410        };
1411        let result = detect_type_checker(&path, LangId::Go, &config);
1412        if tool_available("go") {
1413            let (cmd, _args) = result.unwrap();
1414            // Could be staticcheck or go vet depending on what's installed
1415            assert!(cmd == "go" || cmd == "staticcheck");
1416        } else {
1417            assert!(result.is_none());
1418        }
1419    }
1420    #[test]
1421    fn run_external_tool_capture_nonzero_not_error() {
1422        // `false` exits with code 1 — capture should still return Ok
1423        let result = run_external_tool_capture("false", &[], None, 5);
1424        assert!(result.is_ok(), "capture should not error on non-zero exit");
1425        assert_eq!(result.unwrap().exit_code, 1);
1426    }
1427
1428    #[test]
1429    fn run_external_tool_capture_not_found() {
1430        let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
1431        assert!(result.is_err());
1432        match result.unwrap_err() {
1433            FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
1434            other => panic!("expected NotFound, got: {:?}", other),
1435        }
1436    }
1437}