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