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