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, HashSet};
7use std::io::{ErrorKind, Read};
8use std::path::{Path, PathBuf};
9use std::process::{Child, Command, ExitStatus, 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
25struct SubprocessOutcome {
26    stdout: String,
27    stderr: String,
28    status: ExitStatus,
29}
30
31/// Errors from external tool execution.
32#[derive(Debug)]
33pub enum FormatError {
34    /// The tool binary was not found on PATH.
35    NotFound { tool: String },
36    /// The tool exceeded its timeout and was killed.
37    Timeout { tool: String, timeout_secs: u32 },
38    /// The tool exited with a non-zero status.
39    Failed { tool: String, stderr: String },
40    /// No formatter is configured for this language.
41    UnsupportedLanguage,
42}
43
44/// A configured formatter/checker that cannot be resolved for configure warnings.
45#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
46pub struct MissingTool {
47    pub kind: String,
48    pub language: String,
49    pub tool: String,
50    pub hint: String,
51}
52
53#[derive(Debug, Clone)]
54struct ToolCandidate {
55    tool: String,
56    source: String,
57    args: Vec<String>,
58    required: bool,
59}
60
61#[derive(Debug, Clone)]
62enum ToolDetection {
63    Found(String, Vec<String>),
64    NotConfigured,
65    NotInstalled { tool: String },
66}
67
68impl std::fmt::Display for FormatError {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            FormatError::NotFound { tool } => write!(f, "formatter not found: {}", tool),
72            FormatError::Timeout { tool, timeout_secs } => {
73                write!(f, "formatter '{}' timed out after {}s", tool, timeout_secs)
74            }
75            FormatError::Failed { tool, stderr } => {
76                write!(f, "formatter '{}' failed: {}", tool, stderr)
77            }
78            FormatError::UnsupportedLanguage => write!(f, "unsupported language for formatting"),
79        }
80    }
81}
82
83/// Apply Unix-specific isolation so a kill() on timeout terminates
84/// grandchildren too (e.g. `sh -c 'sleep 60'` orphaning `sleep`).
85///
86/// Without this, killing the immediate child (`sh`) leaves `sleep`
87/// holding stdout/stderr pipes open, and the reader threads block
88/// until `sleep` terminates — turning a 2s timeout into a 60s hang.
89#[cfg(unix)]
90fn isolate_in_process_group(cmd: &mut Command) {
91    use std::os::unix::process::CommandExt;
92    // SAFETY: setsid is async-signal-safe.
93    unsafe {
94        cmd.pre_exec(|| {
95            if libc::setsid() == -1 {
96                return Err(std::io::Error::last_os_error());
97            }
98            Ok(())
99        });
100    }
101}
102
103#[cfg(not(unix))]
104fn isolate_in_process_group(_cmd: &mut Command) {
105    // Best-effort no-op on Windows; child.kill() does not propagate
106    // to grandchildren but reader threads will still close pipes
107    // when the immediate child exits.
108}
109
110/// Kill the child and (on Unix) its entire process group, so orphaned
111/// grandchildren don't keep pipes open after a timeout.
112#[cfg(unix)]
113fn kill_process_tree(child: &mut Child) {
114    let pid = child.id() as i32;
115    if pid > 0 {
116        // SAFETY: killpg with SIGKILL on a process group leader is safe.
117        // Negative pid form (kill -pgid) targets the whole group.
118        unsafe {
119            libc::killpg(pid, libc::SIGKILL);
120        }
121    }
122    let _ = child.kill();
123}
124
125#[cfg(not(unix))]
126fn kill_process_tree(child: &mut Child) {
127    let _ = child.kill();
128}
129
130/// Spawn a subprocess and wait for completion with timeout protection.
131///
132/// Polls `try_wait()` at 50ms intervals. On timeout, kills the child process
133/// and waits for it to exit. Returns `FormatError::NotFound` when the binary
134/// isn't on PATH.
135pub fn run_external_tool(
136    command: &str,
137    args: &[&str],
138    working_dir: Option<&Path>,
139    timeout_secs: u32,
140) -> Result<ExternalToolResult, FormatError> {
141    let mut cmd = Command::new(command);
142    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
143
144    if let Some(dir) = working_dir {
145        cmd.current_dir(dir);
146    }
147
148    isolate_in_process_group(&mut cmd);
149
150    let child = match cmd.spawn() {
151        Ok(c) => c,
152        Err(e) if e.kind() == ErrorKind::NotFound => {
153            return Err(FormatError::NotFound {
154                tool: command.to_string(),
155            });
156        }
157        Err(e) => {
158            return Err(FormatError::Failed {
159                tool: command.to_string(),
160                stderr: e.to_string(),
161            });
162        }
163    };
164
165    let outcome = wait_with_timeout(child, command, timeout_secs)?;
166    let exit_code = outcome.status.code().unwrap_or(-1);
167    if exit_code != 0 {
168        return Err(FormatError::Failed {
169            tool: command.to_string(),
170            stderr: outcome.stderr,
171        });
172    }
173
174    Ok(ExternalToolResult {
175        stdout: outcome.stdout,
176        stderr: outcome.stderr,
177        exit_code,
178    })
179}
180
181fn wait_with_timeout(
182    mut child: Child,
183    command: &str,
184    timeout_secs: u32,
185) -> Result<SubprocessOutcome, FormatError> {
186    let mut stdout_pipe = child.stdout.take().expect("piped stdout");
187    let mut stderr_pipe = child.stderr.take().expect("piped stderr");
188    let stdout_thread = thread::spawn(move || {
189        let mut buf = String::new();
190        let _ = stdout_pipe.read_to_string(&mut buf);
191        buf
192    });
193    let stderr_thread = thread::spawn(move || {
194        let mut buf = String::new();
195        let _ = stderr_pipe.read_to_string(&mut buf);
196        buf
197    });
198    let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
199
200    loop {
201        match child.try_wait() {
202            Ok(Some(status)) => {
203                let stdout = stdout_thread.join().unwrap_or_default();
204                let stderr = stderr_thread.join().unwrap_or_default();
205                return Ok(SubprocessOutcome {
206                    stdout,
207                    stderr,
208                    status,
209                });
210            }
211            Ok(None) => {
212                if Instant::now() >= deadline {
213                    kill_process_tree(&mut child);
214                    let _ = child.wait();
215                    // Do NOT block joining the reader threads — orphaned
216                    // grandchildren may still hold the pipes open even after
217                    // the immediate child is gone. The threads will detach
218                    // and clean up when pipes finally close.
219                    return Err(FormatError::Timeout {
220                        tool: command.to_string(),
221                        timeout_secs,
222                    });
223                }
224                thread::sleep(Duration::from_millis(50));
225            }
226            Err(e) => {
227                kill_process_tree(&mut child);
228                let _ = child.wait();
229                // Same rationale as the timeout branch: don't block on join.
230                return Err(FormatError::Failed {
231                    tool: command.to_string(),
232                    stderr: format!("try_wait error: {}", e),
233                });
234            }
235        }
236    }
237}
238
239/// TTL for tool availability and resolution cache entries.
240const TOOL_CACHE_TTL: Duration = Duration::from_secs(60);
241
242#[derive(Debug, Clone, PartialEq, Eq, Hash)]
243struct ToolCacheKey {
244    command: String,
245    project_root: PathBuf,
246}
247
248static TOOL_RESOLUTION_CACHE: std::sync::LazyLock<
249    Mutex<HashMap<ToolCacheKey, (Option<PathBuf>, Instant)>>,
250> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
251
252static TOOL_AVAILABILITY_CACHE: std::sync::LazyLock<Mutex<HashMap<String, (bool, Instant)>>> =
253    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
254
255fn tool_cache_key(command: &str, project_root: Option<&Path>) -> ToolCacheKey {
256    ToolCacheKey {
257        command: command.to_string(),
258        project_root: project_root.map(Path::to_path_buf).unwrap_or_default(),
259    }
260}
261
262fn availability_cache_key(command: &str, project_root: Option<&Path>) -> String {
263    let root = project_root
264        .map(|path| path.to_string_lossy())
265        .unwrap_or_default();
266    format!("{}\0{}", command, root)
267}
268
269pub fn clear_tool_cache() {
270    if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
271        cache.clear();
272    }
273    if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
274        cache.clear();
275    }
276}
277
278/// Resolve a tool by checking node_modules/.bin relative to project_root, then PATH.
279/// Returns the full path to the tool if found, otherwise None.
280fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
281    let key = tool_cache_key(command, project_root);
282    if let Ok(cache) = TOOL_RESOLUTION_CACHE.lock() {
283        if let Some((resolved, checked_at)) = cache.get(&key) {
284            if checked_at.elapsed() < TOOL_CACHE_TTL {
285                return resolved
286                    .as_ref()
287                    .map(|path| path.to_string_lossy().to_string());
288            }
289        }
290    }
291
292    let resolved = resolve_tool_uncached(command, project_root);
293    if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
294        cache.insert(key, (resolved.clone(), Instant::now()));
295    }
296    resolved.map(|path| path.to_string_lossy().to_string())
297}
298
299fn resolve_tool_uncached(command: &str, project_root: Option<&Path>) -> Option<PathBuf> {
300    // 1. Check node_modules/.bin/<command> relative to project root
301    if let Some(root) = project_root {
302        let local_bin = root.join("node_modules").join(".bin").join(command);
303        if local_bin.exists() {
304            return Some(local_bin);
305        }
306    }
307
308    // 2. Fall back to PATH lookup
309    match Command::new(command)
310        .arg("--version")
311        .stdin(Stdio::null())
312        .stdout(Stdio::null())
313        .stderr(Stdio::null())
314        .spawn()
315    {
316        Ok(mut child) => {
317            let start = Instant::now();
318            let timeout = Duration::from_secs(2);
319            loop {
320                match child.try_wait() {
321                    Ok(Some(status)) => {
322                        return if status.success() {
323                            Some(PathBuf::from(command))
324                        } else {
325                            None
326                        };
327                    }
328                    Ok(None) if start.elapsed() > timeout => {
329                        let _ = child.kill();
330                        let _ = child.wait();
331                        return None;
332                    }
333                    Ok(None) => thread::sleep(Duration::from_millis(50)),
334                    Err(_) => return None,
335                }
336            }
337        }
338        Err(_) => None,
339    }
340}
341
342/// Check if `ruff format` is available with a stable formatter.
343///
344/// Ruff's formatter became stable in v0.1.2. Versions before that output
345/// `NOT_YET_IMPLEMENTED_*` stubs instead of formatted code. We parse the
346/// version from `ruff --version` (format: "ruff X.Y.Z") and require >= 0.1.2.
347/// Falls back to false if ruff is not found or version cannot be parsed.
348fn ruff_format_available(project_root: Option<&Path>) -> bool {
349    let key = availability_cache_key("ruff-format", project_root);
350    if let Ok(cache) = TOOL_AVAILABILITY_CACHE.lock() {
351        if let Some((available, checked_at)) = cache.get(&key) {
352            if checked_at.elapsed() < TOOL_CACHE_TTL {
353                return *available;
354            }
355        }
356    }
357
358    let result = ruff_format_available_uncached(project_root);
359    if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
360        cache.insert(key, (result, Instant::now()));
361    }
362    result
363}
364
365fn ruff_format_available_uncached(project_root: Option<&Path>) -> bool {
366    let command = match resolve_tool("ruff", project_root) {
367        Some(command) => command,
368        None => return false,
369    };
370    let output = match Command::new(&command)
371        .arg("--version")
372        .stdout(Stdio::piped())
373        .stderr(Stdio::null())
374        .output()
375    {
376        Ok(o) => o,
377        Err(_) => return false,
378    };
379
380    let version_str = String::from_utf8_lossy(&output.stdout);
381    // Parse "ruff X.Y.Z" or just "X.Y.Z"
382    let version_part = version_str
383        .trim()
384        .strip_prefix("ruff ")
385        .unwrap_or(version_str.trim());
386
387    let parts: Vec<&str> = version_part.split('.').collect();
388    if parts.len() < 3 {
389        return false;
390    }
391
392    let major: u32 = match parts[0].parse() {
393        Ok(v) => v,
394        Err(_) => return false,
395    };
396    let minor: u32 = match parts[1].parse() {
397        Ok(v) => v,
398        Err(_) => return false,
399    };
400    let patch: u32 = match parts[2].parse() {
401        Ok(v) => v,
402        Err(_) => return false,
403    };
404
405    // Require >= 0.1.2 where ruff format became stable
406    (major, minor, patch) >= (0, 1, 2)
407}
408
409fn resolve_candidate_tool(
410    candidate: &ToolCandidate,
411    project_root: Option<&Path>,
412) -> Option<String> {
413    if candidate.tool == "ruff" && !ruff_format_available(project_root) {
414        return None;
415    }
416
417    resolve_tool(&candidate.tool, project_root)
418}
419
420fn lang_key(lang: LangId) -> &'static str {
421    match lang {
422        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
423        LangId::Python => "python",
424        LangId::Rust => "rust",
425        LangId::Go => "go",
426        LangId::C => "c",
427        LangId::Cpp => "cpp",
428        LangId::Zig => "zig",
429        LangId::CSharp => "csharp",
430        LangId::Bash => "bash",
431        LangId::Solidity => "solidity",
432        LangId::Vue => "vue",
433        LangId::Json => "json",
434        LangId::Scala => "scala",
435        LangId::Java => "java",
436        LangId::Ruby => "ruby",
437        LangId::Kotlin => "kotlin",
438        LangId::Swift => "swift",
439        LangId::Php => "php",
440        LangId::Lua => "lua",
441        LangId::Perl => "perl",
442        LangId::Html => "html",
443        LangId::Markdown => "markdown",
444    }
445}
446
447fn has_formatter_support(lang: LangId) -> bool {
448    matches!(
449        lang,
450        LangId::TypeScript
451            | LangId::JavaScript
452            | LangId::Tsx
453            | LangId::Python
454            | LangId::Rust
455            | LangId::Go
456    )
457}
458
459fn has_checker_support(lang: LangId) -> bool {
460    matches!(
461        lang,
462        LangId::TypeScript
463            | LangId::JavaScript
464            | LangId::Tsx
465            | LangId::Python
466            | LangId::Rust
467            | LangId::Go
468    )
469}
470
471fn formatter_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
472    let project_root = config.project_root.as_deref();
473    if let Some(preferred) = config.formatter.get(lang_key(lang)) {
474        return explicit_formatter_candidate(preferred, file_str);
475    }
476
477    match lang {
478        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
479            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
480                vec![ToolCandidate {
481                    tool: "biome".to_string(),
482                    source: "biome.json".to_string(),
483                    args: vec![
484                        "format".to_string(),
485                        "--write".to_string(),
486                        file_str.to_string(),
487                    ],
488                    required: true,
489                }]
490            } else if has_project_config(
491                project_root,
492                &[
493                    ".prettierrc",
494                    ".prettierrc.json",
495                    ".prettierrc.yml",
496                    ".prettierrc.yaml",
497                    ".prettierrc.js",
498                    ".prettierrc.cjs",
499                    ".prettierrc.mjs",
500                    ".prettierrc.toml",
501                    "prettier.config.js",
502                    "prettier.config.cjs",
503                    "prettier.config.mjs",
504                ],
505            ) {
506                vec![ToolCandidate {
507                    tool: "prettier".to_string(),
508                    source: "Prettier config".to_string(),
509                    args: vec!["--write".to_string(), file_str.to_string()],
510                    required: true,
511                }]
512            } else if has_project_config(project_root, &["deno.json", "deno.jsonc"]) {
513                vec![ToolCandidate {
514                    tool: "deno".to_string(),
515                    source: "deno.json".to_string(),
516                    args: vec!["fmt".to_string(), file_str.to_string()],
517                    required: true,
518                }]
519            } else {
520                Vec::new()
521            }
522        }
523        LangId::Python => {
524            if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
525                || has_pyproject_tool(project_root, "ruff")
526            {
527                vec![ToolCandidate {
528                    tool: "ruff".to_string(),
529                    source: "ruff config".to_string(),
530                    args: vec!["format".to_string(), file_str.to_string()],
531                    required: true,
532                }]
533            } else if has_pyproject_tool(project_root, "black") {
534                vec![ToolCandidate {
535                    tool: "black".to_string(),
536                    source: "pyproject.toml".to_string(),
537                    args: vec![file_str.to_string()],
538                    required: true,
539                }]
540            } else {
541                Vec::new()
542            }
543        }
544        LangId::Rust => {
545            if has_project_config(project_root, &["Cargo.toml"]) {
546                vec![ToolCandidate {
547                    tool: "rustfmt".to_string(),
548                    source: "Cargo.toml".to_string(),
549                    args: vec![file_str.to_string()],
550                    required: true,
551                }]
552            } else {
553                Vec::new()
554            }
555        }
556        LangId::Go => {
557            if has_project_config(project_root, &["go.mod"]) {
558                vec![
559                    ToolCandidate {
560                        tool: "goimports".to_string(),
561                        source: "go.mod".to_string(),
562                        args: vec!["-w".to_string(), file_str.to_string()],
563                        required: false,
564                    },
565                    ToolCandidate {
566                        tool: "gofmt".to_string(),
567                        source: "go.mod".to_string(),
568                        args: vec!["-w".to_string(), file_str.to_string()],
569                        required: true,
570                    },
571                ]
572            } else {
573                Vec::new()
574            }
575        }
576        LangId::C
577        | LangId::Cpp
578        | LangId::Zig
579        | LangId::CSharp
580        | LangId::Bash
581        | LangId::Solidity
582        | LangId::Vue
583        | LangId::Json
584        | LangId::Scala
585        | LangId::Java
586        | LangId::Ruby
587        | LangId::Kotlin
588        | LangId::Swift
589        | LangId::Php
590        | LangId::Lua
591        | LangId::Perl => Vec::new(),
592        LangId::Html => Vec::new(),
593        LangId::Markdown => Vec::new(),
594    }
595}
596
597fn checker_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
598    let project_root = config.project_root.as_deref();
599    if let Some(preferred) = config.checker.get(lang_key(lang)) {
600        return explicit_checker_candidate(preferred, file_str);
601    }
602
603    match lang {
604        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
605            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
606                vec![ToolCandidate {
607                    tool: "biome".to_string(),
608                    source: "biome.json".to_string(),
609                    args: vec!["check".to_string(), file_str.to_string()],
610                    required: true,
611                }]
612            } else if has_project_config(project_root, &["tsconfig.json"]) {
613                vec![ToolCandidate {
614                    tool: "tsc".to_string(),
615                    source: "tsconfig.json".to_string(),
616                    args: vec![
617                        "--noEmit".to_string(),
618                        "--pretty".to_string(),
619                        "false".to_string(),
620                    ],
621                    required: true,
622                }]
623            } else {
624                Vec::new()
625            }
626        }
627        LangId::Python => {
628            if has_project_config(project_root, &["pyrightconfig.json"])
629                || has_pyproject_tool(project_root, "pyright")
630            {
631                vec![ToolCandidate {
632                    tool: "pyright".to_string(),
633                    source: "pyright config".to_string(),
634                    args: vec!["--outputjson".to_string(), file_str.to_string()],
635                    required: true,
636                }]
637            } else if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
638                || has_pyproject_tool(project_root, "ruff")
639            {
640                vec![ToolCandidate {
641                    tool: "ruff".to_string(),
642                    source: "ruff config".to_string(),
643                    args: vec![
644                        "check".to_string(),
645                        "--output-format=json".to_string(),
646                        file_str.to_string(),
647                    ],
648                    required: true,
649                }]
650            } else {
651                Vec::new()
652            }
653        }
654        LangId::Rust => {
655            if has_project_config(project_root, &["Cargo.toml"]) {
656                vec![ToolCandidate {
657                    tool: "cargo".to_string(),
658                    source: "Cargo.toml".to_string(),
659                    args: vec!["check".to_string(), "--message-format=json".to_string()],
660                    required: true,
661                }]
662            } else {
663                Vec::new()
664            }
665        }
666        LangId::Go => {
667            if has_project_config(project_root, &["go.mod"]) {
668                vec![
669                    ToolCandidate {
670                        tool: "staticcheck".to_string(),
671                        source: "go.mod".to_string(),
672                        args: vec![file_str.to_string()],
673                        required: false,
674                    },
675                    ToolCandidate {
676                        tool: "go".to_string(),
677                        source: "go.mod".to_string(),
678                        args: vec!["vet".to_string(), file_str.to_string()],
679                        required: true,
680                    },
681                ]
682            } else {
683                Vec::new()
684            }
685        }
686        LangId::C
687        | LangId::Cpp
688        | LangId::Zig
689        | LangId::CSharp
690        | LangId::Bash
691        | LangId::Solidity
692        | LangId::Vue
693        | LangId::Json
694        | LangId::Scala
695        | LangId::Java
696        | LangId::Ruby
697        | LangId::Kotlin
698        | LangId::Swift
699        | LangId::Php
700        | LangId::Lua
701        | LangId::Perl => Vec::new(),
702        LangId::Html => Vec::new(),
703        LangId::Markdown => Vec::new(),
704    }
705}
706
707fn explicit_formatter_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
708    match name {
709        "none" | "off" | "false" => Vec::new(),
710        "biome" => vec![ToolCandidate {
711            tool: name.to_string(),
712            source: "formatter config".to_string(),
713            args: vec![
714                "format".to_string(),
715                "--write".to_string(),
716                file_str.to_string(),
717            ],
718            required: true,
719        }],
720        "prettier" => vec![ToolCandidate {
721            tool: name.to_string(),
722            source: "formatter config".to_string(),
723            args: vec!["--write".to_string(), file_str.to_string()],
724            required: true,
725        }],
726        "deno" => vec![ToolCandidate {
727            tool: name.to_string(),
728            source: "formatter config".to_string(),
729            args: vec!["fmt".to_string(), file_str.to_string()],
730            required: true,
731        }],
732        "ruff" => vec![ToolCandidate {
733            tool: name.to_string(),
734            source: "formatter config".to_string(),
735            args: vec!["format".to_string(), file_str.to_string()],
736            required: true,
737        }],
738        "black" | "rustfmt" => vec![ToolCandidate {
739            tool: name.to_string(),
740            source: "formatter config".to_string(),
741            args: vec![file_str.to_string()],
742            required: true,
743        }],
744        "goimports" | "gofmt" => vec![ToolCandidate {
745            tool: name.to_string(),
746            source: "formatter config".to_string(),
747            args: vec!["-w".to_string(), file_str.to_string()],
748            required: true,
749        }],
750        _ => Vec::new(),
751    }
752}
753
754fn explicit_checker_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
755    match name {
756        "none" | "off" | "false" => Vec::new(),
757        "tsc" => vec![ToolCandidate {
758            tool: name.to_string(),
759            source: "checker config".to_string(),
760            args: vec![
761                "--noEmit".to_string(),
762                "--pretty".to_string(),
763                "false".to_string(),
764            ],
765            required: true,
766        }],
767        "cargo" => vec![ToolCandidate {
768            tool: name.to_string(),
769            source: "checker config".to_string(),
770            args: vec!["check".to_string(), "--message-format=json".to_string()],
771            required: true,
772        }],
773        "go" => vec![ToolCandidate {
774            tool: name.to_string(),
775            source: "checker config".to_string(),
776            args: vec!["vet".to_string(), file_str.to_string()],
777            required: true,
778        }],
779        "biome" => vec![ToolCandidate {
780            tool: name.to_string(),
781            source: "checker config".to_string(),
782            args: vec!["check".to_string(), file_str.to_string()],
783            required: true,
784        }],
785        "pyright" => vec![ToolCandidate {
786            tool: name.to_string(),
787            source: "checker config".to_string(),
788            args: vec!["--outputjson".to_string(), file_str.to_string()],
789            required: true,
790        }],
791        "ruff" => vec![ToolCandidate {
792            tool: name.to_string(),
793            source: "checker config".to_string(),
794            args: vec![
795                "check".to_string(),
796                "--output-format=json".to_string(),
797                file_str.to_string(),
798            ],
799            required: true,
800        }],
801        "staticcheck" => vec![ToolCandidate {
802            tool: name.to_string(),
803            source: "checker config".to_string(),
804            args: vec![file_str.to_string()],
805            required: true,
806        }],
807        _ => Vec::new(),
808    }
809}
810
811fn resolve_tool_candidates(
812    candidates: Vec<ToolCandidate>,
813    project_root: Option<&Path>,
814) -> ToolDetection {
815    if candidates.is_empty() {
816        return ToolDetection::NotConfigured;
817    }
818
819    let mut missing_required = None;
820    for candidate in candidates {
821        if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
822            return ToolDetection::Found(command, candidate.args);
823        }
824        if candidate.required && missing_required.is_none() {
825            missing_required = Some(candidate.tool);
826        }
827    }
828
829    match missing_required {
830        Some(tool) => ToolDetection::NotInstalled { tool },
831        None => ToolDetection::NotConfigured,
832    }
833}
834
835fn checker_command(candidate: &ToolCandidate, resolved: String) -> String {
836    match candidate.tool.as_str() {
837        "tsc" => resolved,
838        "cargo" => "cargo".to_string(),
839        "go" => "go".to_string(),
840        _ => resolved,
841    }
842}
843
844fn checker_args(candidate: &ToolCandidate) -> Vec<String> {
845    if candidate.tool == "tsc" {
846        vec![
847            "--noEmit".to_string(),
848            "--pretty".to_string(),
849            "false".to_string(),
850        ]
851    } else {
852        candidate.args.clone()
853    }
854}
855
856fn detect_formatter_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
857    let file_str = path.to_string_lossy().to_string();
858    resolve_tool_candidates(
859        formatter_candidates(lang, config, &file_str),
860        config.project_root.as_deref(),
861    )
862}
863
864fn detect_checker_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
865    let file_str = path.to_string_lossy().to_string();
866    let candidates = checker_candidates(lang, config, &file_str);
867    if candidates.is_empty() {
868        return ToolDetection::NotConfigured;
869    }
870
871    let project_root = config.project_root.as_deref();
872    let mut missing_required = None;
873    for candidate in candidates {
874        if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
875            return ToolDetection::Found(
876                checker_command(&candidate, command),
877                checker_args(&candidate),
878            );
879        }
880        if candidate.required && missing_required.is_none() {
881            missing_required = Some(candidate.tool);
882        }
883    }
884
885    match missing_required {
886        Some(tool) => ToolDetection::NotInstalled { tool },
887        None => ToolDetection::NotConfigured,
888    }
889}
890
891fn languages_in_project(project_root: &Path) -> HashSet<LangId> {
892    crate::callgraph::walk_project_files(project_root)
893        .filter_map(|path| detect_language(&path))
894        .collect()
895}
896
897fn placeholder_file_for_language(project_root: &Path, lang: LangId) -> PathBuf {
898    let filename = match lang {
899        LangId::TypeScript => "aft-tool-detection.ts",
900        LangId::Tsx => "aft-tool-detection.tsx",
901        LangId::JavaScript => "aft-tool-detection.js",
902        LangId::Python => "aft-tool-detection.py",
903        LangId::Rust => "aft_tool_detection.rs",
904        LangId::Go => "aft_tool_detection.go",
905        LangId::C => "aft_tool_detection.c",
906        LangId::Cpp => "aft_tool_detection.cpp",
907        LangId::Zig => "aft_tool_detection.zig",
908        LangId::CSharp => "aft_tool_detection.cs",
909        LangId::Bash => "aft_tool_detection.sh",
910        LangId::Solidity => "aft_tool_detection.sol",
911        LangId::Vue => "aft-tool-detection.vue",
912        LangId::Json => "aft-tool-detection.json",
913        LangId::Scala => "aft-tool-detection.scala",
914        LangId::Java => "aft-tool-detection.java",
915        LangId::Ruby => "aft-tool-detection.rb",
916        LangId::Kotlin => "aft-tool-detection.kt",
917        LangId::Swift => "aft-tool-detection.swift",
918        LangId::Php => "aft-tool-detection.php",
919        LangId::Lua => "aft-tool-detection.lua",
920        LangId::Perl => "aft-tool-detection.pl",
921        LangId::Html => "aft-tool-detection.html",
922        LangId::Markdown => "aft-tool-detection.md",
923    };
924    project_root.join(filename)
925}
926
927pub(crate) fn install_hint(tool: &str) -> String {
928    match tool {
929        "biome" => {
930            "Run `bun add -d --workspace-root @biomejs/biome` or install globally.".to_string()
931        }
932        "prettier" => "Run `npm install -D prettier` or install globally.".to_string(),
933        "tsc" => "Run `npm install -D typescript` or install globally.".to_string(),
934        "pyright" | "pyright-langserver" => "Install: `npm install -g pyright`".to_string(),
935        "ruff" => {
936            "Install: `pip install ruff` or your Python package manager equivalent.".to_string()
937        }
938        "black" => {
939            "Install: `pip install black` or your Python package manager equivalent.".to_string()
940        }
941        "rustfmt" => "Install: `rustup component add rustfmt`".to_string(),
942        "rust-analyzer" => "Install: `rustup component add rust-analyzer`".to_string(),
943        "cargo" => "Install Rust from https://rustup.rs/.".to_string(),
944        "go" => "Install Go from https://go.dev/dl/.".to_string(),
945        "gopls" => "Install: `go install golang.org/x/tools/gopls@latest`".to_string(),
946        "bash-language-server" => "Install: `npm install -g bash-language-server`".to_string(),
947        "yaml-language-server" => "Install: `npm install -g yaml-language-server`".to_string(),
948        "typescript-language-server" => {
949            "Install: `npm install -g typescript-language-server typescript`".to_string()
950        }
951        "deno" => "Install Deno from https://deno.com/.".to_string(),
952        "goimports" => "Install: `go install golang.org/x/tools/cmd/goimports@latest`".to_string(),
953        "staticcheck" => {
954            "Install: `go install honnef.co/go/tools/cmd/staticcheck@latest`".to_string()
955        }
956        other => format!("Install `{other}` and ensure it is on PATH."),
957    }
958}
959
960fn configured_tool_hint(tool: &str, source: &str) -> String {
961    format!(
962        "{tool} is configured in {source} but not installed. {}",
963        install_hint(tool)
964    )
965}
966
967fn missing_tool_warning(
968    kind: &str,
969    language: &str,
970    candidate: &ToolCandidate,
971    project_root: Option<&Path>,
972) -> Option<MissingTool> {
973    if !candidate.required || resolve_candidate_tool(candidate, project_root).is_some() {
974        return None;
975    }
976
977    Some(MissingTool {
978        kind: kind.to_string(),
979        language: language.to_string(),
980        tool: candidate.tool.clone(),
981        hint: configured_tool_hint(&candidate.tool, &candidate.source),
982    })
983}
984
985/// Detect configured formatters/checkers that are missing for languages present in the project.
986pub fn detect_missing_tools(project_root: &Path, config: &Config) -> Vec<MissingTool> {
987    let languages = languages_in_project(project_root);
988    let mut warnings = Vec::new();
989    let mut seen = HashSet::new();
990
991    for lang in languages {
992        let language = lang_key(lang);
993        let placeholder = placeholder_file_for_language(project_root, lang);
994        let file_str = placeholder.to_string_lossy().to_string();
995
996        for candidate in formatter_candidates(lang, config, &file_str) {
997            if let Some(warning) = missing_tool_warning(
998                "formatter_not_installed",
999                language,
1000                &candidate,
1001                config.project_root.as_deref(),
1002            ) {
1003                if seen.insert((
1004                    warning.kind.clone(),
1005                    warning.language.clone(),
1006                    warning.tool.clone(),
1007                )) {
1008                    warnings.push(warning);
1009                }
1010            }
1011        }
1012
1013        for candidate in checker_candidates(lang, config, &file_str) {
1014            if let Some(warning) = missing_tool_warning(
1015                "checker_not_installed",
1016                language,
1017                &candidate,
1018                config.project_root.as_deref(),
1019            ) {
1020                if seen.insert((
1021                    warning.kind.clone(),
1022                    warning.language.clone(),
1023                    warning.tool.clone(),
1024                )) {
1025                    warnings.push(warning);
1026                }
1027            }
1028        }
1029    }
1030
1031    warnings.sort_by(|left, right| {
1032        (&left.kind, &left.language, &left.tool).cmp(&(&right.kind, &right.language, &right.tool))
1033    });
1034    warnings
1035}
1036
1037/// Detect the appropriate formatter command and arguments for a file.
1038///
1039/// Priority per language:
1040/// - TypeScript/JavaScript/TSX: `prettier --write <file>`
1041/// - Python: `ruff format <file>` (fallback: `black <file>`)
1042/// - Rust: `rustfmt <file>`
1043/// - Go: `gofmt -w <file>`
1044///
1045/// Returns `None` if no formatter is available for the language.
1046pub fn detect_formatter(
1047    path: &Path,
1048    lang: LangId,
1049    config: &Config,
1050) -> Option<(String, Vec<String>)> {
1051    match detect_formatter_for_path(path, lang, config) {
1052        ToolDetection::Found(cmd, args) => Some((cmd, args)),
1053        ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1054    }
1055}
1056
1057/// Check if any of the given config file names exist in the project root.
1058fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
1059    let root = match project_root {
1060        Some(r) => r,
1061        None => return false,
1062    };
1063    filenames.iter().any(|f| root.join(f).exists())
1064}
1065
1066/// Check if pyproject.toml exists and contains a `[tool.<name>]` section.
1067fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
1068    let root = match project_root {
1069        Some(r) => r,
1070        None => return false,
1071    };
1072    let pyproject = root.join("pyproject.toml");
1073    if !pyproject.exists() {
1074        return false;
1075    }
1076    match std::fs::read_to_string(&pyproject) {
1077        Ok(content) => {
1078            let pattern = format!("[tool.{}]", tool_name);
1079            content.contains(&pattern)
1080        }
1081        Err(_) => false,
1082    }
1083}
1084
1085/// Detect whether a non-zero formatter exit was caused by the formatter
1086/// intentionally excluding the path (per its own config) rather than an
1087/// actual formatter or input error.
1088///
1089/// The patterns below come from real stderr output observed during
1090/// dogfooding. They're intentionally substring-based and case-insensitive
1091/// so minor formatter version differences in wording don't bypass the
1092/// check. Each pattern corresponds to a specific formatter's exclusion
1093/// signal:
1094/// - biome: `"No files were processed in the specified paths."`,
1095///   `"ignored by the configuration"`
1096/// - prettier: `"No files matching the pattern were found"`
1097/// - ruff: `"No Python files found under the given path(s)"`
1098///
1099/// rustfmt and gofmt/goimports rarely scope-restrict and have no known
1100/// stable marker, so they're not detected here. They'll fall through to
1101/// the generic `"error"` reason — acceptable because they almost never
1102/// emit a path-exclusion exit in practice.
1103fn formatter_excluded_path(stderr: &str) -> bool {
1104    let s = stderr.to_lowercase();
1105    s.contains("no files were processed")
1106        || s.contains("ignored by the configuration")
1107        || s.contains("no files matching the pattern")
1108        || s.contains("no python files found")
1109}
1110
1111/// Auto-format a file using the detected formatter for its language.
1112///
1113/// Returns `(formatted, skip_reason)`:
1114/// - `(true, None)` — file was successfully formatted
1115/// - `(false, Some(reason))` — formatting was skipped, reason explains why
1116///
1117/// Skip reasons:
1118/// - `"unsupported_language"` — language has no formatter support in AFT
1119/// - `"no_formatter_configured"` — `format_on_edit=false` or no formatter
1120///   detected for the language in the project
1121/// - `"formatter_not_installed"` — configured formatter binary missing on
1122///   PATH and not in project's `node_modules/.bin`
1123/// - `"formatter_excluded_path"` — formatter ran but refused to process this
1124///   path because the project formatter config (e.g. biome.json `files.includes`,
1125///   prettier `.prettierignore`) excludes it. NOT an error in AFT or the user's
1126///   formatter — the user told the formatter not to touch this path. Agents
1127///   should treat this as informational.
1128/// - `"timeout"` — formatter exceeded `formatter_timeout_secs`
1129/// - `"error"` — formatter exited non-zero with an unrecognized error
1130///   (likely a real bug in the user's input or the formatter itself)
1131pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
1132    // Check if formatting is disabled via plugin config
1133    if !config.format_on_edit {
1134        return (false, Some("no_formatter_configured".to_string()));
1135    }
1136
1137    let lang = match detect_language(path) {
1138        Some(l) => l,
1139        None => {
1140            log::debug!("format: {} (skipped: unsupported_language)", path.display());
1141            return (false, Some("unsupported_language".to_string()));
1142        }
1143    };
1144    if !has_formatter_support(lang) {
1145        log::debug!("format: {} (skipped: unsupported_language)", path.display());
1146        return (false, Some("unsupported_language".to_string()));
1147    }
1148
1149    let (cmd, args) = match detect_formatter_for_path(path, lang, config) {
1150        ToolDetection::Found(cmd, args) => (cmd, args),
1151        ToolDetection::NotConfigured => {
1152            log::debug!(
1153                "format: {} (skipped: no_formatter_configured)",
1154                path.display()
1155            );
1156            return (false, Some("no_formatter_configured".to_string()));
1157        }
1158        ToolDetection::NotInstalled { tool } => {
1159            crate::slog_warn!(
1160                "format: {} (skipped: formatter_not_installed: {})",
1161                path.display(),
1162                tool
1163            );
1164            return (false, Some("formatter_not_installed".to_string()));
1165        }
1166    };
1167
1168    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1169
1170    // Run the formatter in the project root so tool-local config files
1171    // (biome.json, .prettierrc, rustfmt.toml, etc.) are discovered. The
1172    // type-checker path (`validate_full`) already does this via
1173    // `path.parent()`; formatters need the same treatment. Without it,
1174    // formatters silently fall back to built-in defaults when the aft
1175    // process CWD differs from the project root (audit #18).
1176    let working_dir = config.project_root.as_deref();
1177
1178    match run_external_tool(&cmd, &arg_refs, working_dir, config.formatter_timeout_secs) {
1179        Ok(_) => {
1180            crate::slog_info!("format: {} ({})", path.display(), cmd);
1181            (true, None)
1182        }
1183        Err(FormatError::Timeout { .. }) => {
1184            crate::slog_warn!("format: {} (skipped: timeout)", path.display());
1185            (false, Some("timeout".to_string()))
1186        }
1187        Err(FormatError::NotFound { .. }) => {
1188            crate::slog_warn!(
1189                "format: {} (skipped: formatter_not_installed)",
1190                path.display()
1191            );
1192            (false, Some("formatter_not_installed".to_string()))
1193        }
1194        Err(FormatError::Failed { stderr, .. }) => {
1195            // Distinguish "formatter intentionally ignored this path" from
1196            // "formatter actually errored". Many formatters scope themselves
1197            // to a project subtree (biome.json `files.includes`, prettier
1198            // `.prettierignore`, ruff `[tool.ruff]` config) and exit non-zero
1199            // when invoked on a path outside that scope. From AFT's perspective
1200            // that's not an error — the user told the formatter not to touch
1201            // this path. But the previous code returned a generic `"error"`
1202            // skip reason and logged at `debug` (silent under default
1203            // RUST_LOG=info), so the agent had no signal that the file
1204            // landed unformatted. Detect the common stderr fingerprints and
1205            // return a distinct, surfaced skip reason.
1206            if formatter_excluded_path(&stderr) {
1207                crate::slog_info!(
1208                    "format: {} (skipped: formatter_excluded_path; stderr: {})",
1209                    path.display(),
1210                    stderr.lines().next().unwrap_or("").trim()
1211                );
1212                return (false, Some("formatter_excluded_path".to_string()));
1213            }
1214            crate::slog_warn!(
1215                "format: {} (skipped: error: {})",
1216                path.display(),
1217                stderr.lines().next().unwrap_or("unknown").trim()
1218            );
1219            (false, Some("error".to_string()))
1220        }
1221        Err(FormatError::UnsupportedLanguage) => {
1222            log::debug!("format: {} (skipped: unsupported_language)", path.display());
1223            (false, Some("unsupported_language".to_string()))
1224        }
1225    }
1226}
1227
1228/// Spawn a subprocess and capture output regardless of exit code.
1229///
1230/// Unlike `run_external_tool`, this does NOT treat non-zero exit as an error —
1231/// type checkers return non-zero when they find issues, which is expected.
1232/// Returns `FormatError::NotFound` when the binary isn't on PATH, and
1233/// `FormatError::Timeout` if the deadline is exceeded.
1234pub fn run_external_tool_capture(
1235    command: &str,
1236    args: &[&str],
1237    working_dir: Option<&Path>,
1238    timeout_secs: u32,
1239) -> Result<ExternalToolResult, FormatError> {
1240    let mut cmd = Command::new(command);
1241    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
1242
1243    if let Some(dir) = working_dir {
1244        cmd.current_dir(dir);
1245    }
1246
1247    isolate_in_process_group(&mut cmd);
1248
1249    let child = match cmd.spawn() {
1250        Ok(c) => c,
1251        Err(e) if e.kind() == ErrorKind::NotFound => {
1252            return Err(FormatError::NotFound {
1253                tool: command.to_string(),
1254            });
1255        }
1256        Err(e) => {
1257            return Err(FormatError::Failed {
1258                tool: command.to_string(),
1259                stderr: e.to_string(),
1260            });
1261        }
1262    };
1263
1264    let outcome = wait_with_timeout(child, command, timeout_secs)?;
1265    Ok(ExternalToolResult {
1266        stdout: outcome.stdout,
1267        stderr: outcome.stderr,
1268        exit_code: outcome.status.code().unwrap_or(-1),
1269    })
1270}
1271
1272// ============================================================================
1273// Type-checker validation (R017)
1274// ============================================================================
1275
1276/// A structured error from a type checker.
1277#[derive(Debug, Clone, serde::Serialize)]
1278pub struct ValidationError {
1279    pub line: u32,
1280    pub column: u32,
1281    pub message: String,
1282    pub severity: String,
1283}
1284
1285/// Detect the appropriate type checker command and arguments for a file.
1286///
1287/// Returns `(command, args)` for the type checker. The `--noEmit` / equivalent
1288/// flags ensure no output files are produced.
1289///
1290/// Supported:
1291/// - TypeScript/JavaScript/TSX → `npx tsc --noEmit` (fallback: `tsc --noEmit`)
1292/// - Python → `pyright`
1293/// - Rust → `cargo check`
1294/// - Go → `go vet`
1295pub fn detect_type_checker(
1296    path: &Path,
1297    lang: LangId,
1298    config: &Config,
1299) -> Option<(String, Vec<String>)> {
1300    match detect_checker_for_path(path, lang, config) {
1301        ToolDetection::Found(cmd, args) => Some((cmd, args)),
1302        ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1303    }
1304}
1305
1306/// Parse type checker output into structured validation errors.
1307///
1308/// Handles output formats from tsc, pyright (JSON), cargo check (JSON), and go vet.
1309/// Filters to errors related to the edited file where feasible.
1310pub fn parse_checker_output(
1311    stdout: &str,
1312    stderr: &str,
1313    file: &Path,
1314    checker: &str,
1315) -> Vec<ValidationError> {
1316    let checker_name = Path::new(checker)
1317        .file_name()
1318        .and_then(|name| name.to_str())
1319        .unwrap_or(checker);
1320    match checker_name {
1321        "npx" | "tsc" => parse_tsc_output(stdout, stderr, file),
1322        "pyright" => parse_pyright_output(stdout, file),
1323        "cargo" => parse_cargo_output(stdout, stderr, file),
1324        "go" => parse_go_vet_output(stderr, file),
1325        _ => Vec::new(),
1326    }
1327}
1328
1329/// Parse tsc output lines like: `path(line,col): error TSxxxx: message`
1330fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1331    let mut errors = Vec::new();
1332    let file_str = file.to_string_lossy();
1333    // tsc writes diagnostics to stdout (with --pretty false)
1334    let combined = format!("{}{}", stdout, stderr);
1335    for line in combined.lines() {
1336        // Format: path(line,col): severity TSxxxx: message
1337        // or: path(line,col): severity: message
1338        if let Some((loc, rest)) = line.split_once("): ") {
1339            // Check if this error is for our file (compare filename part)
1340            let file_part = loc.split('(').next().unwrap_or("");
1341            if !file_str.ends_with(file_part)
1342                && !file_part.ends_with(&*file_str)
1343                && file_part != &*file_str
1344            {
1345                continue;
1346            }
1347
1348            // Parse (line,col) from the location part
1349            let coords = loc.split('(').last().unwrap_or("");
1350            let parts: Vec<&str> = coords.split(',').collect();
1351            let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
1352            let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1353
1354            // Parse severity and message
1355            let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
1356                ("error".to_string(), msg.to_string())
1357            } else if let Some(msg) = rest.strip_prefix("warning ") {
1358                ("warning".to_string(), msg.to_string())
1359            } else {
1360                ("error".to_string(), rest.to_string())
1361            };
1362
1363            errors.push(ValidationError {
1364                line: line_num,
1365                column: col_num,
1366                message,
1367                severity,
1368            });
1369        }
1370    }
1371    errors
1372}
1373
1374/// Parse pyright JSON output.
1375fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
1376    let mut errors = Vec::new();
1377    let file_str = file.to_string_lossy();
1378
1379    // pyright --outputjson emits JSON with generalDiagnostics array
1380    if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
1381        if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
1382            for diag in diags {
1383                // Filter to our file
1384                let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
1385                if !diag_file.is_empty()
1386                    && !file_str.ends_with(diag_file)
1387                    && !diag_file.ends_with(&*file_str)
1388                    && diag_file != &*file_str
1389                {
1390                    continue;
1391                }
1392
1393                let line_num = diag
1394                    .get("range")
1395                    .and_then(|r| r.get("start"))
1396                    .and_then(|s| s.get("line"))
1397                    .and_then(|l| l.as_u64())
1398                    .unwrap_or(0) as u32;
1399                let col_num = diag
1400                    .get("range")
1401                    .and_then(|r| r.get("start"))
1402                    .and_then(|s| s.get("character"))
1403                    .and_then(|c| c.as_u64())
1404                    .unwrap_or(0) as u32;
1405                let message = diag
1406                    .get("message")
1407                    .and_then(|m| m.as_str())
1408                    .unwrap_or("unknown error")
1409                    .to_string();
1410                let severity = diag
1411                    .get("severity")
1412                    .and_then(|s| s.as_str())
1413                    .unwrap_or("error")
1414                    .to_lowercase();
1415
1416                errors.push(ValidationError {
1417                    line: line_num + 1, // pyright uses 0-indexed lines
1418                    column: col_num,
1419                    message,
1420                    severity,
1421                });
1422            }
1423        }
1424    }
1425    errors
1426}
1427
1428/// Parse cargo check JSON output, filtering to errors in the target file.
1429fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
1430    let mut errors = Vec::new();
1431    let file_str = file.to_string_lossy();
1432
1433    for line in stdout.lines() {
1434        if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
1435            if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
1436                continue;
1437            }
1438            let message_obj = match msg.get("message") {
1439                Some(m) => m,
1440                None => continue,
1441            };
1442
1443            let level = message_obj
1444                .get("level")
1445                .and_then(|l| l.as_str())
1446                .unwrap_or("error");
1447
1448            // Only include errors and warnings, skip notes/help
1449            if level != "error" && level != "warning" {
1450                continue;
1451            }
1452
1453            let text = message_obj
1454                .get("message")
1455                .and_then(|m| m.as_str())
1456                .unwrap_or("unknown error")
1457                .to_string();
1458
1459            // Find the primary span for our file
1460            if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
1461                for span in spans {
1462                    let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
1463                    let is_primary = span
1464                        .get("is_primary")
1465                        .and_then(|p| p.as_bool())
1466                        .unwrap_or(false);
1467
1468                    if !is_primary {
1469                        continue;
1470                    }
1471
1472                    // Filter to our file
1473                    if !file_str.ends_with(span_file)
1474                        && !span_file.ends_with(&*file_str)
1475                        && span_file != &*file_str
1476                    {
1477                        continue;
1478                    }
1479
1480                    let line_num =
1481                        span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
1482                    let col_num = span
1483                        .get("column_start")
1484                        .and_then(|c| c.as_u64())
1485                        .unwrap_or(0) as u32;
1486
1487                    errors.push(ValidationError {
1488                        line: line_num,
1489                        column: col_num,
1490                        message: text.clone(),
1491                        severity: level.to_string(),
1492                    });
1493                }
1494            }
1495        }
1496    }
1497    errors
1498}
1499
1500/// Parse go vet output lines like: `path:line:col: message`
1501fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
1502    let mut errors = Vec::new();
1503    let file_str = file.to_string_lossy();
1504
1505    for line in stderr.lines() {
1506        // Format: path:line:col: message  OR  path:line: message
1507        let parts: Vec<&str> = line.splitn(4, ':').collect();
1508        if parts.len() < 3 {
1509            continue;
1510        }
1511
1512        let err_file = parts[0].trim();
1513        if !file_str.ends_with(err_file)
1514            && !err_file.ends_with(&*file_str)
1515            && err_file != &*file_str
1516        {
1517            continue;
1518        }
1519
1520        let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
1521        let (col_num, message) = if parts.len() >= 4 {
1522            if let Ok(col) = parts[2].trim().parse::<u32>() {
1523                (col, parts[3].trim().to_string())
1524            } else {
1525                // parts[2] is part of the message, not a column
1526                (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
1527            }
1528        } else {
1529            (0, parts[2].trim().to_string())
1530        };
1531
1532        errors.push(ValidationError {
1533            line: line_num,
1534            column: col_num,
1535            message,
1536            severity: "error".to_string(),
1537        });
1538    }
1539    errors
1540}
1541
1542/// Run the project's type checker and return structured validation errors.
1543///
1544/// Returns `(errors, skip_reason)`:
1545/// - `(errors, None)` — checker ran, errors may be empty (= valid code)
1546/// - `([], Some(reason))` — checker was skipped
1547///
1548/// Skip reasons: `"unsupported_language"`, `"no_checker_configured"`,
1549/// `"checker_not_installed"`, `"timeout"`, `"error"`
1550pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
1551    let lang = match detect_language(path) {
1552        Some(l) => l,
1553        None => {
1554            log::debug!(
1555                "validate: {} (skipped: unsupported_language)",
1556                path.display()
1557            );
1558            return (Vec::new(), Some("unsupported_language".to_string()));
1559        }
1560    };
1561    if !has_checker_support(lang) {
1562        log::debug!(
1563            "validate: {} (skipped: unsupported_language)",
1564            path.display()
1565        );
1566        return (Vec::new(), Some("unsupported_language".to_string()));
1567    }
1568
1569    let (cmd, args) = match detect_checker_for_path(path, lang, config) {
1570        ToolDetection::Found(cmd, args) => (cmd, args),
1571        ToolDetection::NotConfigured => {
1572            log::debug!(
1573                "validate: {} (skipped: no_checker_configured)",
1574                path.display()
1575            );
1576            return (Vec::new(), Some("no_checker_configured".to_string()));
1577        }
1578        ToolDetection::NotInstalled { tool } => {
1579            crate::slog_warn!(
1580                "validate: {} (skipped: checker_not_installed: {})",
1581                path.display(),
1582                tool
1583            );
1584            return (Vec::new(), Some("checker_not_installed".to_string()));
1585        }
1586    };
1587
1588    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1589
1590    // Type checkers may need to run from the project root
1591    let working_dir = config.project_root.as_deref();
1592
1593    match run_external_tool_capture(
1594        &cmd,
1595        &arg_refs,
1596        working_dir,
1597        config.type_checker_timeout_secs,
1598    ) {
1599        Ok(result) => {
1600            let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
1601            log::debug!(
1602                "validate: {} ({}, {} errors)",
1603                path.display(),
1604                cmd,
1605                errors.len()
1606            );
1607            (errors, None)
1608        }
1609        Err(FormatError::Timeout { .. }) => {
1610            crate::slog_error!("validate: {} (skipped: timeout)", path.display());
1611            (Vec::new(), Some("timeout".to_string()))
1612        }
1613        Err(FormatError::NotFound { .. }) => {
1614            crate::slog_warn!(
1615                "validate: {} (skipped: checker_not_installed)",
1616                path.display()
1617            );
1618            (Vec::new(), Some("checker_not_installed".to_string()))
1619        }
1620        Err(FormatError::Failed { stderr, .. }) => {
1621            log::debug!(
1622                "validate: {} (skipped: error: {})",
1623                path.display(),
1624                stderr.lines().next().unwrap_or("unknown")
1625            );
1626            (Vec::new(), Some("error".to_string()))
1627        }
1628        Err(FormatError::UnsupportedLanguage) => {
1629            log::debug!(
1630                "validate: {} (skipped: unsupported_language)",
1631                path.display()
1632            );
1633            (Vec::new(), Some("unsupported_language".to_string()))
1634        }
1635    }
1636}
1637
1638#[cfg(test)]
1639mod tests {
1640    use super::*;
1641    use std::fs;
1642    use std::io::Write;
1643    use std::sync::{Mutex, MutexGuard, OnceLock};
1644
1645    /// Serializes tests that mutate the global TOOL_RESOLUTION_CACHE /
1646    /// TOOL_AVAILABILITY_CACHE. Cargo runs tests in parallel by default, and
1647    /// `clear_tool_cache()` from one test would otherwise wipe cached entries
1648    /// that another test had just written, causing flaky CI failures (the
1649    /// `resolve_tool_caches_negative_result_until_clear` failure on Linux
1650    /// runners had exactly this shape).
1651    fn tool_cache_test_lock() -> MutexGuard<'static, ()> {
1652        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1653        let mutex = LOCK.get_or_init(|| Mutex::new(()));
1654        // Recover from poisoning so a panic in one test doesn't permanently
1655        // wedge the rest of the suite.
1656        match mutex.lock() {
1657            Ok(guard) => guard,
1658            Err(poisoned) => poisoned.into_inner(),
1659        }
1660    }
1661
1662    #[test]
1663    fn run_external_tool_not_found() {
1664        let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1665        assert!(result.is_err());
1666        match result.unwrap_err() {
1667            FormatError::NotFound { tool } => {
1668                assert_eq!(tool, "__nonexistent_tool_xyz__");
1669            }
1670            other => panic!("expected NotFound, got: {:?}", other),
1671        }
1672    }
1673
1674    #[test]
1675    fn run_external_tool_timeout_kills_subprocess() {
1676        // Use `sleep 60` as a long-running process, timeout after 1 second
1677        let result = run_external_tool("sleep", &["60"], None, 1);
1678        assert!(result.is_err());
1679        match result.unwrap_err() {
1680            FormatError::Timeout { tool, timeout_secs } => {
1681                assert_eq!(tool, "sleep");
1682                assert_eq!(timeout_secs, 1);
1683            }
1684            other => panic!("expected Timeout, got: {:?}", other),
1685        }
1686    }
1687
1688    #[test]
1689    fn run_external_tool_success() {
1690        let result = run_external_tool("echo", &["hello"], None, 5);
1691        assert!(result.is_ok());
1692        let res = result.unwrap();
1693        assert_eq!(res.exit_code, 0);
1694        assert!(res.stdout.contains("hello"));
1695    }
1696
1697    #[cfg(unix)]
1698    #[test]
1699    fn format_helper_handles_large_stderr_without_deadlock() {
1700        let start = Instant::now();
1701        let result = run_external_tool_capture(
1702            "sh",
1703            &[
1704                "-c",
1705                "i=0; while [ $i -lt 1024 ]; do printf '%1024s\\n' x >&2; i=$((i+1)); done",
1706            ],
1707            None,
1708            2,
1709        )
1710        .expect("large stderr command should complete");
1711
1712        assert_eq!(result.exit_code, 0);
1713        assert!(
1714            result.stderr.len() >= 1024 * 1024,
1715            "expected full stderr capture, got {} bytes",
1716            result.stderr.len()
1717        );
1718        assert!(start.elapsed() < Duration::from_secs(2));
1719    }
1720
1721    #[test]
1722    fn run_external_tool_nonzero_exit() {
1723        // `false` always exits with code 1
1724        let result = run_external_tool("false", &[], None, 5);
1725        assert!(result.is_err());
1726        match result.unwrap_err() {
1727            FormatError::Failed { tool, .. } => {
1728                assert_eq!(tool, "false");
1729            }
1730            other => panic!("expected Failed, got: {:?}", other),
1731        }
1732    }
1733
1734    #[test]
1735    fn auto_format_unsupported_language() {
1736        let dir = tempfile::tempdir().unwrap();
1737        let path = dir.path().join("file.txt");
1738        fs::write(&path, "hello").unwrap();
1739
1740        let config = Config::default();
1741        let (formatted, reason) = auto_format(&path, &config);
1742        assert!(!formatted);
1743        assert_eq!(reason.as_deref(), Some("unsupported_language"));
1744    }
1745
1746    #[test]
1747    fn detect_formatter_rust_when_rustfmt_available() {
1748        let dir = tempfile::tempdir().unwrap();
1749        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1750        let path = dir.path().join("test.rs");
1751        let config = Config {
1752            project_root: Some(dir.path().to_path_buf()),
1753            ..Config::default()
1754        };
1755        let result = detect_formatter(&path, LangId::Rust, &config);
1756        if resolve_tool("rustfmt", config.project_root.as_deref()).is_some() {
1757            let (cmd, args) = result.unwrap();
1758            assert_eq!(cmd, "rustfmt");
1759            assert!(args.iter().any(|a| a.ends_with("test.rs")));
1760        } else {
1761            assert!(result.is_none());
1762        }
1763    }
1764
1765    #[test]
1766    fn detect_formatter_go_mapping() {
1767        let dir = tempfile::tempdir().unwrap();
1768        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1769        let path = dir.path().join("main.go");
1770        let config = Config {
1771            project_root: Some(dir.path().to_path_buf()),
1772            ..Config::default()
1773        };
1774        let result = detect_formatter(&path, LangId::Go, &config);
1775        if resolve_tool("goimports", config.project_root.as_deref()).is_some() {
1776            let (cmd, args) = result.unwrap();
1777            assert_eq!(cmd, "goimports");
1778            assert!(args.contains(&"-w".to_string()));
1779        } else if resolve_tool("gofmt", config.project_root.as_deref()).is_some() {
1780            let (cmd, args) = result.unwrap();
1781            assert_eq!(cmd, "gofmt");
1782            assert!(args.contains(&"-w".to_string()));
1783        } else {
1784            assert!(result.is_none());
1785        }
1786    }
1787
1788    #[test]
1789    fn detect_formatter_python_mapping() {
1790        let dir = tempfile::tempdir().unwrap();
1791        fs::write(dir.path().join("ruff.toml"), "").unwrap();
1792        let path = dir.path().join("main.py");
1793        let config = Config {
1794            project_root: Some(dir.path().to_path_buf()),
1795            ..Config::default()
1796        };
1797        let result = detect_formatter(&path, LangId::Python, &config);
1798        if ruff_format_available(config.project_root.as_deref()) {
1799            let (cmd, args) = result.unwrap();
1800            assert_eq!(cmd, "ruff");
1801            assert!(args.contains(&"format".to_string()));
1802        } else {
1803            assert!(result.is_none());
1804        }
1805    }
1806
1807    #[test]
1808    fn detect_formatter_no_config_returns_none() {
1809        let path = Path::new("test.ts");
1810        let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1811        assert!(
1812            result.is_none(),
1813            "expected no formatter without project config"
1814        );
1815    }
1816
1817    // Unix-only: `resolve_tool_uncached` checks `node_modules/.bin/<name>`
1818    // without trying Windows extensions (.cmd/.exe/.bat). Writing
1819    // `biome.cmd` would not be found by the resolver. A future product
1820    // fix could extend resolve_tool to honor PATHEXT; for now this test
1821    // focuses on the explicit-override semantics on Unix.
1822    #[cfg(unix)]
1823    #[test]
1824    fn detect_formatter_explicit_override() {
1825        // Create a temp dir with a fake node_modules/.bin/biome so resolve_tool finds it
1826        let dir = tempfile::tempdir().unwrap();
1827        let bin_dir = dir.path().join("node_modules").join(".bin");
1828        fs::create_dir_all(&bin_dir).unwrap();
1829        use std::os::unix::fs::PermissionsExt;
1830        let fake = bin_dir.join("biome");
1831        fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
1832        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
1833
1834        let path = Path::new("test.ts");
1835        let mut config = Config {
1836            project_root: Some(dir.path().to_path_buf()),
1837            ..Config::default()
1838        };
1839        config
1840            .formatter
1841            .insert("typescript".to_string(), "biome".to_string());
1842        let result = detect_formatter(path, LangId::TypeScript, &config);
1843        let (cmd, args) = result.unwrap();
1844        assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
1845        assert!(args.contains(&"format".to_string()));
1846        assert!(args.contains(&"--write".to_string()));
1847    }
1848
1849    #[test]
1850    fn resolve_tool_caches_positive_result_until_clear() {
1851        let _guard = tool_cache_test_lock();
1852        clear_tool_cache();
1853        let dir = tempfile::tempdir().unwrap();
1854        let bin_dir = dir.path().join("node_modules").join(".bin");
1855        fs::create_dir_all(&bin_dir).unwrap();
1856        let tool = bin_dir.join("aft-cache-hit-tool");
1857        fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
1858
1859        let first = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
1860        assert_eq!(first.as_deref(), Some(tool.to_string_lossy().as_ref()));
1861
1862        fs::remove_file(&tool).unwrap();
1863        let cached = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
1864        assert_eq!(cached, first);
1865
1866        clear_tool_cache();
1867        assert!(resolve_tool("aft-cache-hit-tool", Some(dir.path())).is_none());
1868    }
1869
1870    #[test]
1871    fn resolve_tool_caches_negative_result_until_clear() {
1872        let _guard = tool_cache_test_lock();
1873        clear_tool_cache();
1874        let dir = tempfile::tempdir().unwrap();
1875        let bin_dir = dir.path().join("node_modules").join(".bin");
1876        let tool = bin_dir.join("aft-cache-miss-tool");
1877
1878        assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
1879
1880        fs::create_dir_all(&bin_dir).unwrap();
1881        fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
1882        assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
1883
1884        clear_tool_cache();
1885        assert_eq!(
1886            resolve_tool("aft-cache-miss-tool", Some(dir.path())).as_deref(),
1887            Some(tool.to_string_lossy().as_ref())
1888        );
1889    }
1890
1891    #[test]
1892    fn auto_format_happy_path_rustfmt() {
1893        if resolve_tool("rustfmt", None).is_none() {
1894            crate::slog_warn!("skipping: rustfmt not available");
1895            return;
1896        }
1897
1898        let dir = tempfile::tempdir().unwrap();
1899        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1900        let path = dir.path().join("test.rs");
1901
1902        let mut f = fs::File::create(&path).unwrap();
1903        writeln!(f, "fn    main()   {{  println!(\"hello\");  }}").unwrap();
1904        drop(f);
1905
1906        let config = Config {
1907            project_root: Some(dir.path().to_path_buf()),
1908            ..Config::default()
1909        };
1910        let (formatted, reason) = auto_format(&path, &config);
1911        assert!(formatted, "expected formatting to succeed");
1912        assert!(reason.is_none());
1913
1914        let content = fs::read_to_string(&path).unwrap();
1915        assert!(
1916            !content.contains("fn    main"),
1917            "expected rustfmt to fix spacing"
1918        );
1919    }
1920
1921    #[test]
1922    fn formatter_excluded_path_detects_biome_messages() {
1923        // Real biome 1.x output when invoked on a path outside files.includes.
1924        let stderr = "format ━━━━━━━━━━━━━━━━━\n\n  × No files were processed in the specified paths.\n\n  i Check your biome.json or biome.jsonc to ensure the paths are not ignored by the configuration.\n";
1925        assert!(
1926            formatter_excluded_path(stderr),
1927            "expected biome exclusion stderr to be detected"
1928        );
1929    }
1930
1931    #[test]
1932    fn formatter_excluded_path_detects_prettier_messages() {
1933        // Real prettier output when given a glob/path that resolves to nothing
1934        // it's allowed to format (after .prettierignore filtering).
1935        let stderr = "[error] No files matching the pattern were found: \"src/scratch.ts\".\n";
1936        assert!(
1937            formatter_excluded_path(stderr),
1938            "expected prettier exclusion stderr to be detected"
1939        );
1940    }
1941
1942    #[test]
1943    fn formatter_excluded_path_detects_ruff_messages() {
1944        // Real ruff output when invoked outside its [tool.ruff] scope.
1945        let stderr = "warning: No Python files found under the given path(s).\n";
1946        assert!(
1947            formatter_excluded_path(stderr),
1948            "expected ruff exclusion stderr to be detected"
1949        );
1950    }
1951
1952    #[test]
1953    fn formatter_excluded_path_is_case_insensitive() {
1954        assert!(formatter_excluded_path("NO FILES WERE PROCESSED"));
1955        assert!(formatter_excluded_path("Ignored By The Configuration"));
1956    }
1957
1958    #[test]
1959    fn formatter_excluded_path_rejects_real_errors() {
1960        // Counter-cases: actual formatter errors must NOT be treated as
1961        // exclusion. This guards against the detection being too greedy.
1962        assert!(!formatter_excluded_path(""));
1963        assert!(!formatter_excluded_path("syntax error: unexpected token"));
1964        assert!(!formatter_excluded_path("formatter crashed: out of memory"));
1965        assert!(!formatter_excluded_path(
1966            "permission denied: /readonly/file"
1967        ));
1968        assert!(!formatter_excluded_path(
1969            "biome internal error: please report"
1970        ));
1971    }
1972
1973    #[test]
1974    fn parse_tsc_output_basic() {
1975        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";
1976        let file = Path::new("src/app.ts");
1977        let errors = parse_tsc_output(stdout, "", file);
1978        assert_eq!(errors.len(), 2);
1979        assert_eq!(errors[0].line, 10);
1980        assert_eq!(errors[0].column, 5);
1981        assert_eq!(errors[0].severity, "error");
1982        assert!(errors[0].message.contains("TS2322"));
1983        assert_eq!(errors[1].line, 20);
1984    }
1985
1986    #[test]
1987    fn parse_tsc_output_filters_other_files() {
1988        let stdout =
1989            "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
1990        let file = Path::new("src/app.ts");
1991        let errors = parse_tsc_output(stdout, "", file);
1992        assert_eq!(errors.len(), 1);
1993        assert_eq!(errors[0].line, 5);
1994    }
1995
1996    #[test]
1997    fn parse_cargo_output_basic() {
1998        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}]}}"#;
1999        let file = Path::new("src/main.rs");
2000        let errors = parse_cargo_output(json_line, "", file);
2001        assert_eq!(errors.len(), 1);
2002        assert_eq!(errors[0].line, 10);
2003        assert_eq!(errors[0].column, 5);
2004        assert_eq!(errors[0].severity, "error");
2005        assert!(errors[0].message.contains("mismatched types"));
2006    }
2007
2008    #[test]
2009    fn parse_cargo_output_skips_notes() {
2010        // Notes and help messages should be filtered out
2011        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}]}}"#;
2012        let file = Path::new("src/main.rs");
2013        let errors = parse_cargo_output(json_line, "", file);
2014        assert_eq!(errors.len(), 0);
2015    }
2016
2017    #[test]
2018    fn parse_cargo_output_filters_other_files() {
2019        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}]}}"#;
2020        let file = Path::new("src/main.rs");
2021        let errors = parse_cargo_output(json_line, "", file);
2022        assert_eq!(errors.len(), 0);
2023    }
2024
2025    #[test]
2026    fn parse_go_vet_output_basic() {
2027        let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
2028        let file = Path::new("main.go");
2029        let errors = parse_go_vet_output(stderr, file);
2030        assert_eq!(errors.len(), 2);
2031        assert_eq!(errors[0].line, 10);
2032        assert_eq!(errors[0].column, 5);
2033        assert!(errors[0].message.contains("unreachable code"));
2034        assert_eq!(errors[1].line, 20);
2035        assert_eq!(errors[1].column, 0);
2036    }
2037
2038    #[test]
2039    fn parse_pyright_output_basic() {
2040        let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
2041        let file = Path::new("test.py");
2042        let errors = parse_pyright_output(stdout, file);
2043        assert_eq!(errors.len(), 1);
2044        assert_eq!(errors[0].line, 5); // 0-indexed → 1-indexed
2045        assert_eq!(errors[0].column, 10);
2046        assert_eq!(errors[0].severity, "error");
2047        assert!(errors[0].message.contains("Type error here"));
2048    }
2049
2050    #[test]
2051    fn validate_full_unsupported_language() {
2052        let dir = tempfile::tempdir().unwrap();
2053        let path = dir.path().join("file.txt");
2054        fs::write(&path, "hello").unwrap();
2055
2056        let config = Config::default();
2057        let (errors, reason) = validate_full(&path, &config);
2058        assert!(errors.is_empty());
2059        assert_eq!(reason.as_deref(), Some("unsupported_language"));
2060    }
2061
2062    #[test]
2063    fn detect_type_checker_rust() {
2064        let dir = tempfile::tempdir().unwrap();
2065        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2066        let path = dir.path().join("src/main.rs");
2067        let config = Config {
2068            project_root: Some(dir.path().to_path_buf()),
2069            ..Config::default()
2070        };
2071        let result = detect_type_checker(&path, LangId::Rust, &config);
2072        if resolve_tool("cargo", config.project_root.as_deref()).is_some() {
2073            let (cmd, args) = result.unwrap();
2074            assert_eq!(cmd, "cargo");
2075            assert!(args.contains(&"check".to_string()));
2076        } else {
2077            assert!(result.is_none());
2078        }
2079    }
2080
2081    #[test]
2082    fn detect_type_checker_go() {
2083        let dir = tempfile::tempdir().unwrap();
2084        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
2085        let path = dir.path().join("main.go");
2086        let config = Config {
2087            project_root: Some(dir.path().to_path_buf()),
2088            ..Config::default()
2089        };
2090        let result = detect_type_checker(&path, LangId::Go, &config);
2091        if resolve_tool("go", config.project_root.as_deref()).is_some() {
2092            let (cmd, _args) = result.unwrap();
2093            // Could be staticcheck or go vet depending on what's installed
2094            assert!(cmd == "go" || cmd == "staticcheck");
2095        } else {
2096            assert!(result.is_none());
2097        }
2098    }
2099    #[test]
2100    fn run_external_tool_capture_nonzero_not_error() {
2101        // `false` exits with code 1 — capture should still return Ok
2102        let result = run_external_tool_capture("false", &[], None, 5);
2103        assert!(result.is_ok(), "capture should not error on non-zero exit");
2104        assert_eq!(result.unwrap().exit_code, 1);
2105    }
2106
2107    #[test]
2108    fn run_external_tool_capture_not_found() {
2109        let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
2110        assert!(result.is_err());
2111        match result.unwrap_err() {
2112            FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
2113            other => panic!("expected NotFound, got: {:?}", other),
2114        }
2115    }
2116}