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