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