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