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    pub truncated: bool,
24}
25
26struct SubprocessOutcome {
27    stdout: String,
28    stderr: String,
29    status: ExitStatus,
30    truncated: bool,
31}
32
33/// Errors from external tool execution.
34#[derive(Debug)]
35pub enum FormatError {
36    /// The tool binary was not found on PATH.
37    NotFound { tool: String },
38    /// The tool exceeded its timeout and was killed.
39    Timeout { tool: String, timeout_secs: u32 },
40    /// The tool exited with a non-zero status.
41    Failed { tool: String, stderr: String },
42    /// No formatter is configured for this language.
43    UnsupportedLanguage,
44}
45
46/// A configured formatter/checker that cannot be resolved for configure warnings.
47#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
48pub struct MissingTool {
49    pub kind: String,
50    pub language: String,
51    pub tool: String,
52    pub hint: String,
53}
54
55#[derive(Debug, Clone)]
56struct ToolCandidate {
57    tool: String,
58    source: String,
59    args: Vec<String>,
60    required: bool,
61}
62
63#[derive(Debug, Clone)]
64enum ToolDetection {
65    Found(String, Vec<String>),
66    NotConfigured,
67    NotInstalled { tool: String },
68}
69
70impl std::fmt::Display for FormatError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            FormatError::NotFound { tool } => write!(f, "formatter not found: {}", tool),
74            FormatError::Timeout { tool, timeout_secs } => {
75                write!(f, "formatter '{}' timed out after {}s", tool, timeout_secs)
76            }
77            FormatError::Failed { tool, stderr } => {
78                write!(f, "formatter '{}' failed: {}", tool, stderr)
79            }
80            FormatError::UnsupportedLanguage => write!(f, "unsupported language for formatting"),
81        }
82    }
83}
84
85/// Apply Unix-specific isolation so a kill() on timeout terminates
86/// grandchildren too (e.g. `sh -c 'sleep 60'` orphaning `sleep`).
87///
88/// Without this, killing the immediate child (`sh`) leaves `sleep`
89/// holding stdout/stderr pipes open, and the reader threads block
90/// until `sleep` terminates — turning a 2s timeout into a 60s hang.
91#[cfg(unix)]
92fn isolate_in_process_group(cmd: &mut Command) {
93    use std::os::unix::process::CommandExt;
94    // SAFETY: setsid is async-signal-safe.
95    unsafe {
96        cmd.pre_exec(|| {
97            if libc::setsid() == -1 {
98                return Err(std::io::Error::last_os_error());
99            }
100            Ok(())
101        });
102    }
103}
104
105#[cfg(not(unix))]
106fn isolate_in_process_group(_cmd: &mut Command) {
107    // Best-effort no-op outside Unix. Windows timeout cleanup uses taskkill /T
108    // in kill_process_tree so .cmd wrappers and grandchildren are terminated.
109}
110
111/// Kill the child and (on Unix) its entire process group, so orphaned
112/// grandchildren don't keep pipes open after a timeout.
113#[cfg(unix)]
114fn kill_process_tree(child: &mut Child) {
115    let pid = child.id() as i32;
116    if pid > 0 {
117        // SAFETY: killpg with SIGKILL on a process group leader is safe.
118        // Negative pid form (kill -pgid) targets the whole group.
119        unsafe {
120            libc::killpg(pid, libc::SIGKILL);
121        }
122    }
123    let _ = child.kill();
124}
125
126#[cfg(windows)]
127fn kill_process_tree(child: &mut Child) {
128    let pid = child.id().to_string();
129    let _ = Command::new("taskkill")
130        .args(["/PID", pid.as_str(), "/T", "/F"])
131        .stdin(Stdio::null())
132        .stdout(Stdio::null())
133        .stderr(Stdio::null())
134        .status();
135    let _ = child.kill();
136}
137
138#[cfg(not(any(unix, windows)))]
139fn kill_process_tree(child: &mut Child) {
140    let _ = child.kill();
141}
142
143/// Spawn a subprocess and wait for completion with timeout protection.
144///
145/// Polls `try_wait()` at 50ms intervals. On timeout, kills the child process
146/// and waits for it to exit. Returns `FormatError::NotFound` when the binary
147/// isn't on PATH.
148pub fn run_external_tool(
149    command: &str,
150    args: &[&str],
151    working_dir: Option<&Path>,
152    timeout_secs: u32,
153) -> Result<ExternalToolResult, FormatError> {
154    let mut cmd = Command::new(command);
155    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
156
157    if let Some(dir) = working_dir {
158        cmd.current_dir(dir);
159    }
160
161    isolate_in_process_group(&mut cmd);
162
163    let child = match cmd.spawn() {
164        Ok(c) => c,
165        Err(e) if e.kind() == ErrorKind::NotFound => {
166            return Err(FormatError::NotFound {
167                tool: command.to_string(),
168            });
169        }
170        Err(e) => {
171            return Err(FormatError::Failed {
172                tool: command.to_string(),
173                stderr: e.to_string(),
174            });
175        }
176    };
177
178    let outcome = wait_with_timeout(child, command, timeout_secs)?;
179    let exit_code = outcome.status.code().unwrap_or(-1);
180    if exit_code != 0 {
181        return Err(FormatError::Failed {
182            tool: command.to_string(),
183            stderr: outcome.stderr,
184        });
185    }
186
187    Ok(ExternalToolResult {
188        stdout: outcome.stdout,
189        stderr: outcome.stderr,
190        exit_code,
191        truncated: outcome.truncated,
192    })
193}
194
195const MAX_CAPTURE_BYTES: usize = 16 * 1024 * 1024;
196
197fn wait_with_timeout(
198    mut child: Child,
199    command: &str,
200    timeout_secs: u32,
201) -> Result<SubprocessOutcome, FormatError> {
202    let stdout_pipe = child.stdout.take().expect("piped stdout");
203    let stderr_pipe = child.stderr.take().expect("piped stderr");
204    let stdout_thread =
205        thread::spawn(move || read_bounded_to_string(stdout_pipe, MAX_CAPTURE_BYTES));
206    let stderr_thread =
207        thread::spawn(move || read_bounded_to_string(stderr_pipe, MAX_CAPTURE_BYTES));
208    let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
209
210    loop {
211        match child.try_wait() {
212            Ok(Some(status)) => {
213                let (stdout, stdout_truncated) = stdout_thread.join().unwrap_or_default();
214                let (stderr, stderr_truncated) = stderr_thread.join().unwrap_or_default();
215                return Ok(SubprocessOutcome {
216                    stdout,
217                    stderr,
218                    status,
219                    truncated: stdout_truncated || stderr_truncated,
220                });
221            }
222            Ok(None) => {
223                if Instant::now() >= deadline {
224                    kill_process_tree(&mut child);
225                    let _ = child.wait();
226                    // Do NOT block joining the reader threads — orphaned
227                    // grandchildren may still hold the pipes open even after
228                    // the immediate child is gone. The threads will detach
229                    // and clean up when pipes finally close.
230                    return Err(FormatError::Timeout {
231                        tool: command.to_string(),
232                        timeout_secs,
233                    });
234                }
235                thread::sleep(Duration::from_millis(50));
236            }
237            Err(e) => {
238                kill_process_tree(&mut child);
239                let _ = child.wait();
240                // Same rationale as the timeout branch: don't block on join.
241                return Err(FormatError::Failed {
242                    tool: command.to_string(),
243                    stderr: format!("try_wait error: {}", e),
244                });
245            }
246        }
247    }
248}
249
250fn read_bounded_to_string<R: Read>(mut reader: R, limit: usize) -> (String, bool) {
251    let mut bytes = Vec::with_capacity(limit.min(8192));
252    let mut scratch = [0u8; 8192];
253    let mut truncated = false;
254
255    loop {
256        let read = match reader.read(&mut scratch) {
257            Ok(0) => break,
258            Ok(read) => read,
259            Err(_) => break,
260        };
261
262        let remaining = limit.saturating_sub(bytes.len());
263        if remaining > 0 {
264            let keep = remaining.min(read);
265            bytes.extend_from_slice(&scratch[..keep]);
266            if keep < read {
267                truncated = true;
268            }
269        } else {
270            truncated = true;
271        }
272    }
273
274    (String::from_utf8_lossy(&bytes).into_owned(), truncated)
275}
276
277/// TTL for tool availability and resolution cache entries.
278const TOOL_CACHE_TTL: Duration = Duration::from_secs(60);
279
280#[derive(Debug, Clone, PartialEq, Eq, Hash)]
281struct ToolCacheKey {
282    command: String,
283    project_root: PathBuf,
284}
285
286static TOOL_RESOLUTION_CACHE: std::sync::LazyLock<
287    Mutex<HashMap<ToolCacheKey, (Option<PathBuf>, Instant)>>,
288> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
289
290static TOOL_AVAILABILITY_CACHE: std::sync::LazyLock<Mutex<HashMap<String, (bool, Instant)>>> =
291    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
292
293fn tool_cache_key(command: &str, project_root: Option<&Path>) -> ToolCacheKey {
294    ToolCacheKey {
295        command: command.to_string(),
296        project_root: project_root.map(Path::to_path_buf).unwrap_or_default(),
297    }
298}
299
300fn availability_cache_key(command: &str, project_root: Option<&Path>) -> String {
301    let root = project_root
302        .map(|path| path.to_string_lossy())
303        .unwrap_or_default();
304    format!("{}\0{}", command, root)
305}
306
307pub fn clear_tool_cache() {
308    if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
309        cache.clear();
310    }
311    if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
312        cache.clear();
313    }
314}
315
316/// Resolve a tool by checking node_modules/.bin relative to project_root, then PATH.
317/// Returns the full path to the tool if found, otherwise None.
318fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
319    let key = tool_cache_key(command, project_root);
320    if let Ok(cache) = TOOL_RESOLUTION_CACHE.lock() {
321        if let Some((resolved, checked_at)) = cache.get(&key) {
322            if checked_at.elapsed() < TOOL_CACHE_TTL {
323                return resolved
324                    .as_ref()
325                    .map(|path| path.to_string_lossy().to_string());
326            }
327        }
328    }
329
330    let resolved = resolve_tool_uncached(command, project_root);
331    if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
332        cache.insert(key, (resolved.clone(), Instant::now()));
333    }
334    resolved.map(|path| path.to_string_lossy().to_string())
335}
336
337pub(crate) fn resolve_tool_uncached(command: &str, project_root: Option<&Path>) -> Option<PathBuf> {
338    // 1. Check node_modules/.bin/<command> relative to project root. On
339    // Windows, package managers usually create .cmd/.bat/.ps1 shims rather
340    // than extensionless executables, so probe PATHEXT-style variants too.
341    if let Some(root) = project_root {
342        let local_bin_dir = root.join("node_modules").join(".bin");
343        for local_bin in local_node_bin_candidates(&local_bin_dir, command) {
344            if local_bin.exists() {
345                return Some(local_bin);
346            }
347        }
348    }
349
350    // 2. PATH via `which` + manual walk (mirrors magic-context findOnPath).
351    if let Some(path) = crate::tool_path::resolve_on_path(command) {
352        return Some(path);
353    }
354
355    // 3. Fall back to well-known install locations the editor's PATH may
356    // not contain. GitHub issue #47: macOS GUI launches (Spotlight, Dock,
357    // Alfred) and some Linux desktop launchers drop /opt/homebrew/bin and
358    // similar from PATH, making PATH lookups fail even though the user
359    // genuinely has the tool installed. Returning the absolute path here
360    // means downstream `Command::new(resolved)` works regardless.
361    try_well_known_path_lookup(command)
362}
363
364fn local_node_bin_candidates(bin_dir: &Path, command: &str) -> Vec<PathBuf> {
365    #[cfg(windows)]
366    {
367        let command_path = Path::new(command);
368        if command_path.extension().is_some() {
369            return vec![bin_dir.join(command)];
370        }
371
372        let mut candidates = vec![bin_dir.join(command)];
373        candidates.extend(
374            windows_local_node_bin_extensions(std::env::var_os("PATHEXT").as_deref())
375                .into_iter()
376                .map(|ext| bin_dir.join(format!("{command}{ext}"))),
377        );
378        candidates
379    }
380
381    #[cfg(not(windows))]
382    {
383        vec![bin_dir.join(command)]
384    }
385}
386
387#[cfg(any(windows, test))]
388fn windows_local_node_bin_extensions(pathext: Option<&std::ffi::OsStr>) -> Vec<String> {
389    const DEFAULT_ORDER: [&str; 4] = [".cmd", ".exe", ".bat", ".ps1"];
390    let allowed: HashSet<&str> = DEFAULT_ORDER.into_iter().collect();
391
392    let mut ordered = Vec::new();
393    if let Some(pathext) = pathext.and_then(|value| value.to_str()) {
394        for ext in pathext.split(';') {
395            let normalized = ext.trim().to_ascii_lowercase();
396            if allowed.contains(normalized.as_str()) && !ordered.contains(&normalized) {
397                ordered.push(normalized);
398            }
399        }
400    }
401
402    for ext in DEFAULT_ORDER {
403        if !ordered.iter().any(|existing| existing == ext) {
404            ordered.push(ext.to_string());
405        }
406    }
407
408    ordered
409}
410
411/// Look up `command` in the well-known install locations that GUI-launched
412/// editors commonly miss from PATH. Returns the absolute path so the caller
413/// invokes the tool via `Command::new(absolute_path)` regardless of PATH.
414///
415/// Search order is built by `well_known_search_paths`:
416/// 1. `/opt/homebrew/bin` (Apple Silicon Homebrew)
417/// 2. `/usr/local/bin` (Intel Mac Homebrew + most manual Linux installs)
418/// 3. `/usr/local/go/bin` (official go.dev installer)
419/// 4. `/usr/bin` (distro-packaged tools)
420/// 5. `/snap/bin` (snap-packaged tools)
421/// 6. `$HOME/.cargo/bin` (cargo install — rustfmt, etc.)
422/// 7. `$HOME/go/bin` (`go install` default GOPATH layout)
423/// 8. `$HOME/.local/bin` (pip --user, pipx, npm prefix, many shell scripts)
424///
425/// Each candidate is verified to (a) exist as a regular file and (b) be
426/// executable; we don't spawn `--version` here because spawning an
427/// absolute-path candidate that doesn't accept `--version` would emit a
428/// false negative (and Rust's `fs::metadata` is much cheaper than a spawn).
429fn try_well_known_path_lookup(command: &str) -> Option<PathBuf> {
430    // Test-only escape hatch: integration tests that need to assert
431    // "tool not installed" semantics set AFT_DISABLE_WELL_KNOWN_LOOKUP=1
432    // so CI runners with a system tsc/biome/etc. at /usr/local/bin don't
433    // silently make those tests pass. Production callers never set this.
434    if std::env::var_os("AFT_DISABLE_WELL_KNOWN_LOOKUP").is_some() {
435        return None;
436    }
437    if cfg!(windows) {
438        for dir in crate::tool_path::well_known_windows_bin_dirs(
439            std::env::var_os("USERPROFILE").as_deref(),
440        ) {
441            if let Some(found) = crate::tool_path::probe_tool_in_dir(&dir, command) {
442                return Some(found);
443            }
444        }
445        return None;
446    }
447    let candidates = well_known_search_paths(command, std::env::var_os("HOME").as_deref());
448    try_well_known_path_lookup_in(&candidates)
449}
450
451/// Build the candidate path list for the given command name and HOME value.
452/// Extracted so tests can drive the lookup with a controlled HOME without
453/// mutating process-global env vars.
454fn well_known_search_paths(command: &str, home: Option<&std::ffi::OsStr>) -> Vec<PathBuf> {
455    let mut candidates: Vec<PathBuf> = Vec::with_capacity(8);
456    candidates.push(PathBuf::from("/opt/homebrew/bin").join(command));
457    candidates.push(PathBuf::from("/usr/local/bin").join(command));
458    // System/distro install locations a GUI-launched editor's truncated PATH
459    // often misses. /usr/local/go/bin is where the official go.dev installer
460    // puts the Go toolchain (gofmt, go); /snap/bin and /usr/bin cover
461    // distro-packaged installs (Go from apt/snap, etc.).
462    candidates.push(PathBuf::from("/usr/local/go/bin").join(command));
463    candidates.push(PathBuf::from("/usr/bin").join(command));
464    candidates.push(PathBuf::from("/snap/bin").join(command));
465    if let Some(home) = home {
466        let home_path = PathBuf::from(home);
467        candidates.push(home_path.join(".cargo/bin").join(command));
468        candidates.push(home_path.join("go/bin").join(command));
469        candidates.push(home_path.join(".local/bin").join(command));
470    }
471    candidates
472}
473
474/// Build the candidate path list for the given command name using well-known
475/// Windows install locations. Extracted so tests can drive the lookup with a
476/// controlled USERPROFILE without mutating process-global env vars.
477///
478/// Search order:
479/// 1. `C:\Go\bin\<command>.exe` — Windows Go installer (default path)
480/// 2. `C:\Program Files\Go\bin\<command>.exe` — Windows Go installer (Program Files)
481/// 3. `%USERPROFILE%\.cargo\bin\<command>.exe` — `cargo install`
482/// 4. `%USERPROFILE%\go\bin\<command>.exe` — `go install` with default GOPATH
483///
484/// Walk a pre-built candidate list, returning the first file that exists and
485/// is executable. Extracted from `try_well_known_path_lookup` so tests can
486/// inject candidates anchored at a tempdir.
487fn try_well_known_path_lookup_in(candidates: &[PathBuf]) -> Option<PathBuf> {
488    for candidate in candidates {
489        if let Ok(metadata) = std::fs::metadata(candidate) {
490            if metadata.is_file() && is_executable(&metadata) {
491                return Some(candidate.clone());
492            }
493        }
494    }
495    None
496}
497
498#[cfg(unix)]
499fn is_executable(metadata: &std::fs::Metadata) -> bool {
500    use std::os::unix::fs::PermissionsExt;
501    metadata.permissions().mode() & 0o111 != 0
502}
503
504#[cfg(not(unix))]
505fn is_executable(_metadata: &std::fs::Metadata) -> bool {
506    // Windows: the well-known Windows paths in `try_well_known_path_lookup`
507    // construct .exe paths which are always executable (or the metadata check
508    // already filters out non-files). This stub exists for compile-time
509    // completeness on the POSIX candidate path used during non-Windows builds.
510    true
511}
512
513/// Check if `ruff format` is available with a stable formatter.
514///
515/// Ruff's formatter became stable in v0.1.2. Versions before that output
516/// `NOT_YET_IMPLEMENTED_*` stubs instead of formatted code. We parse the
517/// version from `ruff --version` (format: "ruff X.Y.Z") and require >= 0.1.2.
518/// Falls back to false if ruff is not found or version cannot be parsed.
519/// Whether a tool referenced by configure missing-tool warnings is resolvable.
520pub(crate) fn tool_available_for_missing_warning(tool: &str, project_root: Option<&Path>) -> bool {
521    if tool == "ruff" {
522        return resolve_tool_uncached("ruff", project_root).is_some()
523            && ruff_format_available(project_root);
524    }
525    resolve_tool_uncached(tool, project_root).is_some()
526}
527
528fn ruff_format_available(project_root: Option<&Path>) -> bool {
529    let key = availability_cache_key("ruff-format", project_root);
530    if let Ok(cache) = TOOL_AVAILABILITY_CACHE.lock() {
531        if let Some((available, checked_at)) = cache.get(&key) {
532            if checked_at.elapsed() < TOOL_CACHE_TTL {
533                return *available;
534            }
535        }
536    }
537
538    let result = ruff_format_available_uncached(project_root);
539    if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
540        cache.insert(key, (result, Instant::now()));
541    }
542    result
543}
544
545fn ruff_format_available_uncached(project_root: Option<&Path>) -> bool {
546    let command = match resolve_tool("ruff", project_root) {
547        Some(command) => command,
548        None => return false,
549    };
550    let output = match Command::new(&command)
551        .arg("--version")
552        .stdout(Stdio::piped())
553        .stderr(Stdio::null())
554        .output()
555    {
556        Ok(o) => o,
557        Err(_) => return false,
558    };
559
560    let version_str = String::from_utf8_lossy(&output.stdout);
561    // Parse "ruff X.Y.Z" or just "X.Y.Z"
562    let version_part = version_str
563        .trim()
564        .strip_prefix("ruff ")
565        .unwrap_or(version_str.trim());
566
567    let parts: Vec<&str> = version_part.split('.').collect();
568    if parts.len() < 3 {
569        return false;
570    }
571
572    let major: u32 = match parts[0].parse() {
573        Ok(v) => v,
574        Err(_) => return false,
575    };
576    let minor: u32 = match parts[1].parse() {
577        Ok(v) => v,
578        Err(_) => return false,
579    };
580    let patch: u32 = match parts[2].parse() {
581        Ok(v) => v,
582        Err(_) => return false,
583    };
584
585    // Require >= 0.1.2 where ruff format became stable
586    (major, minor, patch) >= (0, 1, 2)
587}
588
589fn resolve_candidate_tool(
590    candidate: &ToolCandidate,
591    project_root: Option<&Path>,
592    require_ruff_format: bool,
593) -> Option<String> {
594    if require_ruff_format && candidate.tool == "ruff" && !ruff_format_available(project_root) {
595        return None;
596    }
597
598    resolve_tool(&candidate.tool, project_root)
599}
600
601fn lang_key(lang: LangId) -> &'static str {
602    match lang {
603        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
604        LangId::Python => "python",
605        LangId::Rust => "rust",
606        LangId::Go => "go",
607        LangId::C => "c",
608        LangId::Cpp => "cpp",
609        LangId::Zig => "zig",
610        LangId::CSharp => "csharp",
611        LangId::Bash => "bash",
612        LangId::Solidity => "solidity",
613        LangId::Scss => "scss",
614        LangId::Vue => "vue",
615        LangId::Json => "json",
616        LangId::Scala => "scala",
617        LangId::Java => "java",
618        LangId::Ruby => "ruby",
619        LangId::Kotlin => "kotlin",
620        LangId::Swift => "swift",
621        LangId::Php => "php",
622        LangId::Lua => "lua",
623        LangId::Perl => "perl",
624        LangId::Html => "html",
625        LangId::Markdown => "markdown",
626        LangId::Yaml => "yaml",
627        LangId::Pascal => "pascal",
628    }
629}
630
631fn has_formatter_support(lang: LangId) -> bool {
632    matches!(
633        lang,
634        LangId::TypeScript
635            | LangId::JavaScript
636            | LangId::Tsx
637            | LangId::Python
638            | LangId::Rust
639            | LangId::Go
640    )
641}
642
643fn has_checker_support(lang: LangId) -> bool {
644    matches!(
645        lang,
646        LangId::TypeScript
647            | LangId::JavaScript
648            | LangId::Tsx
649            | LangId::Python
650            | LangId::Rust
651            | LangId::Go
652    )
653}
654
655fn formatter_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
656    let project_root = config.project_root.as_deref();
657    if let Some(preferred) = config.formatter.get(lang_key(lang)) {
658        return explicit_formatter_candidate(preferred, file_str);
659    }
660
661    match lang {
662        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
663            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
664                vec![ToolCandidate {
665                    tool: "biome".to_string(),
666                    source: "biome.json".to_string(),
667                    args: vec![
668                        "format".to_string(),
669                        "--write".to_string(),
670                        file_str.to_string(),
671                    ],
672                    required: true,
673                }]
674            } else if has_project_config(
675                project_root,
676                &[".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts"],
677            ) {
678                vec![ToolCandidate {
679                    tool: "oxfmt".to_string(),
680                    source: "oxfmt config".to_string(),
681                    args: vec!["--write".to_string(), file_str.to_string()],
682                    required: true,
683                }]
684            } else if has_project_config(
685                project_root,
686                &[
687                    ".prettierrc",
688                    ".prettierrc.json",
689                    ".prettierrc.yml",
690                    ".prettierrc.yaml",
691                    ".prettierrc.js",
692                    ".prettierrc.cjs",
693                    ".prettierrc.mjs",
694                    ".prettierrc.toml",
695                    "prettier.config.js",
696                    "prettier.config.cjs",
697                    "prettier.config.mjs",
698                ],
699            ) {
700                vec![ToolCandidate {
701                    tool: "prettier".to_string(),
702                    source: "Prettier config".to_string(),
703                    args: vec!["--write".to_string(), file_str.to_string()],
704                    required: true,
705                }]
706            } else if has_project_config(project_root, &["deno.json", "deno.jsonc"]) {
707                vec![ToolCandidate {
708                    tool: "deno".to_string(),
709                    source: "deno.json".to_string(),
710                    args: vec!["fmt".to_string(), file_str.to_string()],
711                    required: true,
712                }]
713            } else {
714                Vec::new()
715            }
716        }
717        LangId::Python => {
718            if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
719                || has_pyproject_tool(project_root, "ruff")
720            {
721                vec![ToolCandidate {
722                    tool: "ruff".to_string(),
723                    source: "ruff config".to_string(),
724                    args: vec!["format".to_string(), file_str.to_string()],
725                    required: true,
726                }]
727            } else if has_pyproject_tool(project_root, "black") {
728                vec![ToolCandidate {
729                    tool: "black".to_string(),
730                    source: "pyproject.toml".to_string(),
731                    args: vec![file_str.to_string()],
732                    required: true,
733                }]
734            } else {
735                Vec::new()
736            }
737        }
738        LangId::Rust => {
739            if has_project_config(project_root, &["Cargo.toml"]) {
740                vec![ToolCandidate {
741                    tool: "rustfmt".to_string(),
742                    source: "Cargo.toml".to_string(),
743                    args: vec![file_str.to_string()],
744                    required: true,
745                }]
746            } else {
747                Vec::new()
748            }
749        }
750        LangId::Go => {
751            if has_project_config(project_root, &["go.mod"]) {
752                vec![
753                    ToolCandidate {
754                        tool: "goimports".to_string(),
755                        source: "go.mod".to_string(),
756                        args: vec!["-w".to_string(), file_str.to_string()],
757                        required: false,
758                    },
759                    ToolCandidate {
760                        tool: "gofmt".to_string(),
761                        source: "go.mod".to_string(),
762                        args: vec!["-w".to_string(), file_str.to_string()],
763                        required: true,
764                    },
765                ]
766            } else {
767                Vec::new()
768            }
769        }
770        LangId::C
771        | LangId::Cpp
772        | LangId::Zig
773        | LangId::CSharp
774        | LangId::Bash
775        | LangId::Solidity
776        | LangId::Scss
777        | LangId::Vue
778        | LangId::Json
779        | LangId::Scala
780        | LangId::Java
781        | LangId::Ruby
782        | LangId::Kotlin
783        | LangId::Swift
784        | LangId::Php
785        | LangId::Lua
786        | LangId::Perl
787        | LangId::Pascal => Vec::new(),
788        LangId::Html => Vec::new(),
789        LangId::Markdown => Vec::new(),
790        LangId::Yaml => Vec::new(),
791    }
792}
793
794fn checker_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
795    let project_root = config.project_root.as_deref();
796    if let Some(preferred) = config.checker.get(lang_key(lang)) {
797        return explicit_checker_candidate(preferred, file_str);
798    }
799
800    match lang {
801        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
802            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
803                vec![ToolCandidate {
804                    tool: "biome".to_string(),
805                    source: "biome.json".to_string(),
806                    args: vec![
807                        "check".to_string(),
808                        "--reporter=json".to_string(),
809                        file_str.to_string(),
810                    ],
811                    required: true,
812                }]
813            } else if has_project_config(project_root, &["tsconfig.json"]) {
814                vec![ToolCandidate {
815                    tool: "tsc".to_string(),
816                    source: "tsconfig.json".to_string(),
817                    args: vec![
818                        "--noEmit".to_string(),
819                        "--pretty".to_string(),
820                        "false".to_string(),
821                    ],
822                    required: true,
823                }]
824            } else {
825                Vec::new()
826            }
827        }
828        LangId::Python => {
829            if has_project_config(project_root, &["pyrightconfig.json"])
830                || has_pyproject_tool(project_root, "pyright")
831            {
832                vec![ToolCandidate {
833                    tool: "pyright".to_string(),
834                    source: "pyright config".to_string(),
835                    args: vec!["--outputjson".to_string(), file_str.to_string()],
836                    required: true,
837                }]
838            } else if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
839                || has_pyproject_tool(project_root, "ruff")
840            {
841                vec![ToolCandidate {
842                    tool: "ruff".to_string(),
843                    source: "ruff config".to_string(),
844                    args: vec![
845                        "check".to_string(),
846                        "--output-format=json".to_string(),
847                        file_str.to_string(),
848                    ],
849                    required: true,
850                }]
851            } else {
852                Vec::new()
853            }
854        }
855        LangId::Rust => {
856            if has_project_config(project_root, &["Cargo.toml"]) {
857                vec![ToolCandidate {
858                    tool: "cargo".to_string(),
859                    source: "Cargo.toml".to_string(),
860                    args: vec!["check".to_string(), "--message-format=json".to_string()],
861                    required: true,
862                }]
863            } else {
864                Vec::new()
865            }
866        }
867        LangId::Go => {
868            if has_project_config(project_root, &["go.mod"]) {
869                vec![
870                    ToolCandidate {
871                        tool: "staticcheck".to_string(),
872                        source: "go.mod".to_string(),
873                        args: vec!["-f".to_string(), "json".to_string(), file_str.to_string()],
874                        required: false,
875                    },
876                    ToolCandidate {
877                        tool: "go".to_string(),
878                        source: "go.mod".to_string(),
879                        args: vec!["vet".to_string(), file_str.to_string()],
880                        required: true,
881                    },
882                ]
883            } else {
884                Vec::new()
885            }
886        }
887        LangId::C
888        | LangId::Cpp
889        | LangId::Zig
890        | LangId::CSharp
891        | LangId::Bash
892        | LangId::Solidity
893        | LangId::Scss
894        | LangId::Vue
895        | LangId::Json
896        | LangId::Scala
897        | LangId::Java
898        | LangId::Ruby
899        | LangId::Kotlin
900        | LangId::Swift
901        | LangId::Php
902        | LangId::Lua
903        | LangId::Perl
904        | LangId::Pascal => Vec::new(),
905        LangId::Html => Vec::new(),
906        LangId::Markdown => Vec::new(),
907        LangId::Yaml => Vec::new(),
908    }
909}
910
911fn explicit_formatter_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
912    match name {
913        "none" | "off" | "false" => Vec::new(),
914        "biome" => vec![ToolCandidate {
915            tool: name.to_string(),
916            source: "formatter config".to_string(),
917            args: vec![
918                "format".to_string(),
919                "--write".to_string(),
920                file_str.to_string(),
921            ],
922            required: true,
923        }],
924        "oxfmt" => vec![ToolCandidate {
925            tool: name.to_string(),
926            source: "formatter config".to_string(),
927            args: vec!["--write".to_string(), file_str.to_string()],
928            required: true,
929        }],
930        "prettier" => vec![ToolCandidate {
931            tool: name.to_string(),
932            source: "formatter config".to_string(),
933            args: vec!["--write".to_string(), file_str.to_string()],
934            required: true,
935        }],
936        "deno" => vec![ToolCandidate {
937            tool: name.to_string(),
938            source: "formatter config".to_string(),
939            args: vec!["fmt".to_string(), file_str.to_string()],
940            required: true,
941        }],
942        "ruff" => vec![ToolCandidate {
943            tool: name.to_string(),
944            source: "formatter config".to_string(),
945            args: vec!["format".to_string(), file_str.to_string()],
946            required: true,
947        }],
948        "black" | "rustfmt" => vec![ToolCandidate {
949            tool: name.to_string(),
950            source: "formatter config".to_string(),
951            args: vec![file_str.to_string()],
952            required: true,
953        }],
954        "goimports" | "gofmt" => vec![ToolCandidate {
955            tool: name.to_string(),
956            source: "formatter config".to_string(),
957            args: vec!["-w".to_string(), file_str.to_string()],
958            required: true,
959        }],
960        _ => Vec::new(),
961    }
962}
963
964fn explicit_checker_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
965    match name {
966        "none" | "off" | "false" => Vec::new(),
967        "tsc" | "tsgo" => vec![ToolCandidate {
968            tool: name.to_string(),
969            source: "checker config".to_string(),
970            args: vec![
971                "--noEmit".to_string(),
972                "--pretty".to_string(),
973                "false".to_string(),
974            ],
975            required: true,
976        }],
977        "cargo" => vec![ToolCandidate {
978            tool: name.to_string(),
979            source: "checker config".to_string(),
980            args: vec!["check".to_string(), "--message-format=json".to_string()],
981            required: true,
982        }],
983        "go" => vec![ToolCandidate {
984            tool: name.to_string(),
985            source: "checker config".to_string(),
986            args: vec!["vet".to_string(), file_str.to_string()],
987            required: true,
988        }],
989        "biome" => vec![ToolCandidate {
990            tool: name.to_string(),
991            source: "checker config".to_string(),
992            args: vec![
993                "check".to_string(),
994                "--reporter=json".to_string(),
995                file_str.to_string(),
996            ],
997            required: true,
998        }],
999        "pyright" => vec![ToolCandidate {
1000            tool: name.to_string(),
1001            source: "checker config".to_string(),
1002            args: vec!["--outputjson".to_string(), file_str.to_string()],
1003            required: true,
1004        }],
1005        "ruff" => vec![ToolCandidate {
1006            tool: name.to_string(),
1007            source: "checker config".to_string(),
1008            args: vec![
1009                "check".to_string(),
1010                "--output-format=json".to_string(),
1011                file_str.to_string(),
1012            ],
1013            required: true,
1014        }],
1015        "staticcheck" => vec![ToolCandidate {
1016            tool: name.to_string(),
1017            source: "checker config".to_string(),
1018            args: vec!["-f".to_string(), "json".to_string(), file_str.to_string()],
1019            required: true,
1020        }],
1021        _ => Vec::new(),
1022    }
1023}
1024
1025fn resolve_tool_candidates(
1026    candidates: Vec<ToolCandidate>,
1027    project_root: Option<&Path>,
1028    require_ruff_format: bool,
1029) -> ToolDetection {
1030    if candidates.is_empty() {
1031        return ToolDetection::NotConfigured;
1032    }
1033
1034    let mut missing_required = None;
1035    for candidate in candidates {
1036        if let Some(command) = resolve_candidate_tool(&candidate, project_root, require_ruff_format)
1037        {
1038            return ToolDetection::Found(command, candidate.args);
1039        }
1040        if candidate.required && missing_required.is_none() {
1041            missing_required = Some(candidate.tool);
1042        }
1043    }
1044
1045    match missing_required {
1046        Some(tool) => ToolDetection::NotInstalled { tool },
1047        None => ToolDetection::NotConfigured,
1048    }
1049}
1050
1051fn checker_command(_candidate: &ToolCandidate, resolved: String) -> String {
1052    resolved
1053}
1054
1055fn checker_args(candidate: &ToolCandidate) -> Vec<String> {
1056    if candidate.tool == "tsc" || candidate.tool == "tsgo" {
1057        vec![
1058            "--noEmit".to_string(),
1059            "--pretty".to_string(),
1060            "false".to_string(),
1061        ]
1062    } else {
1063        candidate.args.clone()
1064    }
1065}
1066
1067fn detect_formatter_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
1068    let file_str = path.to_string_lossy().to_string();
1069    resolve_tool_candidates(
1070        formatter_candidates(lang, config, &file_str),
1071        config.project_root.as_deref(),
1072        true,
1073    )
1074}
1075
1076fn detect_checker_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
1077    let file_str = path.to_string_lossy().to_string();
1078    let candidates = checker_candidates(lang, config, &file_str);
1079    if candidates.is_empty() {
1080        return ToolDetection::NotConfigured;
1081    }
1082
1083    let project_root = config.project_root.as_deref();
1084    let mut missing_required = None;
1085    for candidate in candidates {
1086        if let Some(command) = resolve_candidate_tool(&candidate, project_root, false) {
1087            return ToolDetection::Found(
1088                checker_command(&candidate, command),
1089                checker_args(&candidate),
1090            );
1091        }
1092        if candidate.required && missing_required.is_none() {
1093            missing_required = Some(candidate.tool);
1094        }
1095    }
1096
1097    match missing_required {
1098        Some(tool) => ToolDetection::NotInstalled { tool },
1099        None => ToolDetection::NotConfigured,
1100    }
1101}
1102
1103fn languages_in_project(project_root: &Path) -> HashSet<LangId> {
1104    crate::callgraph::walk_project_files(project_root)
1105        .filter_map(|path| detect_language(&path))
1106        .collect()
1107}
1108
1109fn placeholder_file_for_language(project_root: &Path, lang: LangId) -> PathBuf {
1110    let filename = match lang {
1111        LangId::TypeScript => "aft-tool-detection.ts",
1112        LangId::Tsx => "aft-tool-detection.tsx",
1113        LangId::JavaScript => "aft-tool-detection.js",
1114        LangId::Python => "aft-tool-detection.py",
1115        LangId::Rust => "aft_tool_detection.rs",
1116        LangId::Go => "aft_tool_detection.go",
1117        LangId::C => "aft_tool_detection.c",
1118        LangId::Cpp => "aft_tool_detection.cpp",
1119        LangId::Zig => "aft_tool_detection.zig",
1120        LangId::CSharp => "aft_tool_detection.cs",
1121        LangId::Bash => "aft_tool_detection.sh",
1122        LangId::Solidity => "aft_tool_detection.sol",
1123        LangId::Scss => "aft-tool-detection.scss",
1124        LangId::Vue => "aft-tool-detection.vue",
1125        LangId::Json => "aft-tool-detection.json",
1126        LangId::Scala => "aft-tool-detection.scala",
1127        LangId::Java => "aft-tool-detection.java",
1128        LangId::Ruby => "aft-tool-detection.rb",
1129        LangId::Kotlin => "aft-tool-detection.kt",
1130        LangId::Swift => "aft-tool-detection.swift",
1131        LangId::Php => "aft-tool-detection.php",
1132        LangId::Lua => "aft-tool-detection.lua",
1133        LangId::Perl => "aft-tool-detection.pl",
1134        LangId::Html => "aft-tool-detection.html",
1135        LangId::Markdown => "aft-tool-detection.md",
1136        LangId::Yaml => "aft-tool-detection.yaml",
1137        LangId::Pascal => "aft-tool-detection.pas",
1138    };
1139    project_root.join(filename)
1140}
1141
1142pub(crate) fn install_hint(tool: &str) -> String {
1143    match tool {
1144        "biome" => {
1145            "Run `bun add -d --workspace-root @biomejs/biome` or install globally.".to_string()
1146        }
1147        "oxfmt" => "Run `npm install -D oxfmt` or install globally.".to_string(),
1148        "prettier" => "Run `npm install -D prettier` or install globally.".to_string(),
1149        "tsc" => "Run `npm install -D typescript` or install globally.".to_string(),
1150        "tsgo" => {
1151            "Run `npm install -D @typescript/native-preview` or install globally.".to_string()
1152        }
1153        "pyright" | "pyright-langserver" => "Install: `npm install -g pyright`".to_string(),
1154        "ruff" => {
1155            "Install: `pip install ruff` or your Python package manager equivalent.".to_string()
1156        }
1157        "black" => {
1158            "Install: `pip install black` or your Python package manager equivalent.".to_string()
1159        }
1160        "rustfmt" => "Install: `rustup component add rustfmt`".to_string(),
1161        "rust-analyzer" => "Install: `rustup component add rust-analyzer`".to_string(),
1162        "cargo" => "Install Rust from https://rustup.rs/.".to_string(),
1163        "go" => if cfg!(windows) {
1164            "Install Go from https://go.dev/dl/. Common install paths:\
1165                 C:\\Go\\bin, C:\\Program Files\\Go\\bin. \
1166                 GUI-launched editors often don't inherit login-shell PATH."
1167        } else {
1168            "Install Go from https://go.dev/dl/, or — if it's already installed —\
1169                 ensure its bin directory is on PATH (Homebrew typically uses\
1170                 /opt/homebrew/bin on Apple Silicon, /usr/local/bin on Intel macOS).\
1171                 GUI-launched editors often don't inherit login-shell PATH."
1172        }
1173        .to_string(),
1174        "gopls" => "Install: `go install golang.org/x/tools/gopls@latest`".to_string(),
1175        "bash-language-server" => "Install: `npm install -g bash-language-server`".to_string(),
1176        "yaml-language-server" => "Install: `npm install -g yaml-language-server`".to_string(),
1177        "typescript-language-server" => {
1178            "Install: `npm install -g typescript-language-server typescript`".to_string()
1179        }
1180        "deno" => "Install Deno from https://deno.com/.".to_string(),
1181        "goimports" => "Install: `go install golang.org/x/tools/cmd/goimports@latest`".to_string(),
1182        "staticcheck" => {
1183            "Install: `go install honnef.co/go/tools/cmd/staticcheck@latest`".to_string()
1184        }
1185        other => format!("Install `{other}` and ensure it is on PATH."),
1186    }
1187}
1188
1189fn configured_tool_hint(tool: &str, source: &str) -> String {
1190    // GitHub issue #47: editors launched from a non-login GUI shell (Spotlight,
1191    // Dock, Alfred, etc.) often don't inherit the user's full PATH, so a tool
1192    // that's installed but lives under /opt/homebrew/bin, ~/.cargo/bin, or
1193    // similar can fail this lookup. We already check those well-known
1194    // locations in `resolve_tool_uncached`; if we still didn't find the tool,
1195    // it's genuinely missing OR sits in an unusual install prefix.
1196    //
1197    // Word the message so users know to check both "is it installed at all"
1198    // and "is it on AFT's PATH" — rather than implying definite absence.
1199    format!(
1200        "{tool} is configured in {source} but was not found on PATH or in common install locations. {}",
1201        install_hint(tool)
1202    )
1203}
1204
1205fn missing_tool_warning(
1206    kind: &str,
1207    language: &str,
1208    candidate: &ToolCandidate,
1209    project_root: Option<&Path>,
1210    require_ruff_format: bool,
1211) -> Option<MissingTool> {
1212    if !candidate.required
1213        || resolve_candidate_tool(candidate, project_root, require_ruff_format).is_some()
1214    {
1215        return None;
1216    }
1217
1218    Some(MissingTool {
1219        kind: kind.to_string(),
1220        language: language.to_string(),
1221        tool: candidate.tool.clone(),
1222        hint: configured_tool_hint(&candidate.tool, &candidate.source),
1223    })
1224}
1225
1226/// Detect configured formatters/checkers that are missing for languages present in the project.
1227pub fn detect_missing_tools(project_root: &Path, config: &Config) -> Vec<MissingTool> {
1228    let languages = languages_in_project(project_root);
1229    let mut warnings = Vec::new();
1230    let mut seen = HashSet::new();
1231
1232    for lang in languages {
1233        let language = lang_key(lang);
1234        let placeholder = placeholder_file_for_language(project_root, lang);
1235        let file_str = placeholder.to_string_lossy().to_string();
1236
1237        for candidate in formatter_candidates(lang, config, &file_str) {
1238            if let Some(warning) = missing_tool_warning(
1239                "formatter_not_installed",
1240                language,
1241                &candidate,
1242                config.project_root.as_deref(),
1243                true,
1244            ) {
1245                if seen.insert((
1246                    warning.kind.clone(),
1247                    warning.language.clone(),
1248                    warning.tool.clone(),
1249                )) {
1250                    warnings.push(warning);
1251                }
1252            }
1253        }
1254
1255        for candidate in checker_candidates(lang, config, &file_str) {
1256            if let Some(warning) = missing_tool_warning(
1257                "checker_not_installed",
1258                language,
1259                &candidate,
1260                config.project_root.as_deref(),
1261                false,
1262            ) {
1263                if seen.insert((
1264                    warning.kind.clone(),
1265                    warning.language.clone(),
1266                    warning.tool.clone(),
1267                )) {
1268                    warnings.push(warning);
1269                }
1270            }
1271        }
1272    }
1273
1274    warnings.sort_by(|left, right| {
1275        (&left.kind, &left.language, &left.tool).cmp(&(&right.kind, &right.language, &right.tool))
1276    });
1277    warnings
1278}
1279
1280/// Detect the appropriate formatter command and arguments for a file.
1281///
1282/// Priority per language:
1283/// - TypeScript/JavaScript/TSX: `prettier --write <file>`
1284/// - Python: `ruff format <file>` (fallback: `black <file>`)
1285/// - Rust: `rustfmt <file>`
1286/// - Go: `gofmt -w <file>`
1287///
1288/// Returns `None` if no formatter is available for the language.
1289pub fn detect_formatter(
1290    path: &Path,
1291    lang: LangId,
1292    config: &Config,
1293) -> Option<(String, Vec<String>)> {
1294    match detect_formatter_for_path(path, lang, config) {
1295        ToolDetection::Found(cmd, args) => Some((cmd, args)),
1296        ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1297    }
1298}
1299
1300/// Check if any of the given config file names exist in the project root.
1301fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
1302    let root = match project_root {
1303        Some(r) => r,
1304        None => return false,
1305    };
1306    filenames.iter().any(|f| root.join(f).exists())
1307}
1308
1309/// Check if pyproject.toml exists and contains a `[tool.<name>]` section.
1310fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
1311    let root = match project_root {
1312        Some(r) => r,
1313        None => return false,
1314    };
1315    let pyproject = root.join("pyproject.toml");
1316    if !pyproject.exists() {
1317        return false;
1318    }
1319    match std::fs::read_to_string(&pyproject) {
1320        Ok(content) => {
1321            let pattern = format!("[tool.{}]", tool_name);
1322            content.contains(&pattern)
1323        }
1324        Err(_) => false,
1325    }
1326}
1327
1328/// Detect whether a non-zero formatter exit was caused by the formatter
1329/// intentionally excluding the path (per its own config) rather than an
1330/// actual formatter or input error.
1331///
1332/// The patterns below come from real stderr output observed during
1333/// dogfooding. They're intentionally substring-based and case-insensitive
1334/// so minor formatter version differences in wording don't bypass the
1335/// check. Each pattern corresponds to a specific formatter's exclusion
1336/// signal:
1337/// - biome: `"No files were processed in the specified paths."`,
1338///   `"ignored by the configuration"`
1339/// - oxfmt: `"Expected at least one target file"`,
1340///   `"No files found matching the given patterns"`
1341/// - prettier: `"No files matching the pattern were found"`
1342/// - ruff: `"No Python files found under the given path(s)"`
1343///
1344/// rustfmt and gofmt/goimports rarely scope-restrict and have no known
1345/// stable marker, so they're not detected here. They'll fall through to
1346/// the generic `"error"` reason — acceptable because they almost never
1347/// emit a path-exclusion exit in practice.
1348fn formatter_excluded_path(stderr: &str) -> bool {
1349    let s = stderr.to_lowercase();
1350    s.contains("no files were processed")
1351        || s.contains("ignored by the configuration")
1352        || s.contains("expected at least one target file")
1353        || s.contains("no files found matching the given patterns")
1354        || s.contains("no files matching the pattern")
1355        || s.contains("no python files found")
1356}
1357
1358/// Auto-format a file using the detected formatter for its language.
1359///
1360/// Returns `(formatted, skip_reason)`:
1361/// - `(true, None)` — file was successfully formatted
1362/// - `(false, Some(reason))` — formatting was skipped, reason explains why
1363///
1364/// Skip reasons:
1365/// - `"unsupported_language"` — language has no formatter support in AFT
1366/// - `"no_formatter_configured"` — `format_on_edit=false` or no formatter
1367///   detected for the language in the project
1368/// - `"formatter_not_installed"` — configured formatter binary missing on
1369///   PATH and not in project's `node_modules/.bin`
1370/// - `"formatter_excluded_path"` — formatter ran but refused to process this
1371///   path because the project formatter config (e.g. biome.json `files.includes`,
1372///   prettier `.prettierignore`) excludes it. NOT an error in AFT or the user's
1373///   formatter — the user told the formatter not to touch this path. Agents
1374///   should treat this as informational.
1375/// - `"timeout"` — formatter exceeded `formatter_timeout_secs`
1376/// - `"error"` — formatter exited non-zero with an unrecognized error
1377///   (likely a real bug in the user's input or the formatter itself)
1378pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
1379    // Check if formatting is disabled via plugin config
1380    if !config.format_on_edit {
1381        return (false, Some("no_formatter_configured".to_string()));
1382    }
1383
1384    let lang = match detect_language(path) {
1385        Some(l) => l,
1386        None => {
1387            log::debug!("format: {} (skipped: unsupported_language)", path.display());
1388            return (false, Some("unsupported_language".to_string()));
1389        }
1390    };
1391    if !has_formatter_support(lang) {
1392        log::debug!("format: {} (skipped: unsupported_language)", path.display());
1393        return (false, Some("unsupported_language".to_string()));
1394    }
1395
1396    let (cmd, args) = match detect_formatter_for_path(path, lang, config) {
1397        ToolDetection::Found(cmd, args) => (cmd, args),
1398        ToolDetection::NotConfigured => {
1399            log::debug!(
1400                "format: {} (skipped: no_formatter_configured)",
1401                path.display()
1402            );
1403            return (false, Some("no_formatter_configured".to_string()));
1404        }
1405        ToolDetection::NotInstalled { tool } => {
1406            crate::slog_warn!(
1407                "format: {} (skipped: formatter_not_installed: {})",
1408                path.display(),
1409                tool
1410            );
1411            return (false, Some("formatter_not_installed".to_string()));
1412        }
1413    };
1414
1415    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1416
1417    // Run the formatter in the project root so tool-local config files
1418    // (biome.json, .prettierrc, rustfmt.toml, etc.) are discovered. The
1419    // type-checker path (`validate_full`) already does this via
1420    // `path.parent()`; formatters need the same treatment. Without it,
1421    // formatters silently fall back to built-in defaults when the aft
1422    // process CWD differs from the project root (audit #18).
1423    let working_dir = config.project_root.as_deref();
1424
1425    match run_external_tool(&cmd, &arg_refs, working_dir, config.formatter_timeout_secs) {
1426        Ok(_) => {
1427            crate::slog_info!("format: {} ({})", path.display(), cmd);
1428            (true, None)
1429        }
1430        Err(FormatError::Timeout { .. }) => {
1431            crate::slog_warn!("format: {} (skipped: timeout)", path.display());
1432            (false, Some("timeout".to_string()))
1433        }
1434        Err(FormatError::NotFound { .. }) => {
1435            crate::slog_warn!(
1436                "format: {} (skipped: formatter_not_installed)",
1437                path.display()
1438            );
1439            (false, Some("formatter_not_installed".to_string()))
1440        }
1441        Err(FormatError::Failed { stderr, .. }) => {
1442            // Distinguish "formatter intentionally ignored this path" from
1443            // "formatter actually errored". Many formatters scope themselves
1444            // to a project subtree (biome.json `files.includes`, prettier
1445            // `.prettierignore`, ruff `[tool.ruff]` config) and exit non-zero
1446            // when invoked on a path outside that scope. From AFT's perspective
1447            // that's not an error — the user told the formatter not to touch
1448            // this path. But the previous code returned a generic `"error"`
1449            // skip reason and logged at `debug` (silent under default
1450            // RUST_LOG=info), so the agent had no signal that the file
1451            // landed unformatted. Detect the common stderr fingerprints and
1452            // return a distinct, surfaced skip reason.
1453            if formatter_excluded_path(&stderr) {
1454                crate::slog_info!(
1455                    "format: {} (skipped: formatter_excluded_path; stderr: {})",
1456                    path.display(),
1457                    stderr.lines().next().unwrap_or("").trim()
1458                );
1459                return (false, Some("formatter_excluded_path".to_string()));
1460            }
1461            crate::slog_warn!(
1462                "format: {} (skipped: error: {})",
1463                path.display(),
1464                stderr.lines().next().unwrap_or("unknown").trim()
1465            );
1466            (false, Some("error".to_string()))
1467        }
1468        Err(FormatError::UnsupportedLanguage) => {
1469            log::debug!("format: {} (skipped: unsupported_language)", path.display());
1470            (false, Some("unsupported_language".to_string()))
1471        }
1472    }
1473}
1474
1475/// Spawn a subprocess and capture output regardless of exit code.
1476///
1477/// Unlike `run_external_tool`, this does NOT treat non-zero exit as an error —
1478/// type checkers return non-zero when they find issues, which is expected.
1479/// Returns `FormatError::NotFound` when the binary isn't on PATH, and
1480/// `FormatError::Timeout` if the deadline is exceeded.
1481pub fn run_external_tool_capture(
1482    command: &str,
1483    args: &[&str],
1484    working_dir: Option<&Path>,
1485    timeout_secs: u32,
1486) -> Result<ExternalToolResult, FormatError> {
1487    let mut cmd = Command::new(command);
1488    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
1489
1490    if let Some(dir) = working_dir {
1491        cmd.current_dir(dir);
1492    }
1493
1494    isolate_in_process_group(&mut cmd);
1495
1496    let child = match cmd.spawn() {
1497        Ok(c) => c,
1498        Err(e) if e.kind() == ErrorKind::NotFound => {
1499            return Err(FormatError::NotFound {
1500                tool: command.to_string(),
1501            });
1502        }
1503        Err(e) => {
1504            return Err(FormatError::Failed {
1505                tool: command.to_string(),
1506                stderr: e.to_string(),
1507            });
1508        }
1509    };
1510
1511    let outcome = wait_with_timeout(child, command, timeout_secs)?;
1512    Ok(ExternalToolResult {
1513        stdout: outcome.stdout,
1514        stderr: outcome.stderr,
1515        exit_code: outcome.status.code().unwrap_or(-1),
1516        truncated: outcome.truncated,
1517    })
1518}
1519
1520// ============================================================================
1521// Type-checker validation (R017)
1522// ============================================================================
1523
1524/// A structured error from a type checker.
1525#[derive(Debug, Clone, serde::Serialize)]
1526pub struct ValidationError {
1527    pub line: u32,
1528    pub column: u32,
1529    pub message: String,
1530    pub severity: String,
1531}
1532
1533/// Detect the appropriate type checker command and arguments for a file.
1534///
1535/// Returns `(command, args)` for the type checker. The `--noEmit` / equivalent
1536/// flags ensure no output files are produced.
1537///
1538/// Supported:
1539/// - TypeScript/JavaScript/TSX → `tsc --noEmit` (or `tsgo --noEmit` when explicitly configured)
1540/// - Python → `pyright`
1541/// - Rust → `cargo check`
1542/// - Go → `go vet`
1543pub fn detect_type_checker(
1544    path: &Path,
1545    lang: LangId,
1546    config: &Config,
1547) -> Option<(String, Vec<String>)> {
1548    match detect_checker_for_path(path, lang, config) {
1549        ToolDetection::Found(cmd, args) => Some((cmd, args)),
1550        ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1551    }
1552}
1553
1554/// Parse type checker output into structured validation errors.
1555///
1556/// Handles output formats from tsc, pyright (JSON), cargo check (JSON), and go vet.
1557/// Filters to errors related to the edited file where feasible.
1558pub fn parse_checker_output(
1559    stdout: &str,
1560    stderr: &str,
1561    file: &Path,
1562    checker: &str,
1563) -> Vec<ValidationError> {
1564    let checker_name = checker_executable_name(checker);
1565    match checker_name.as_str() {
1566        "npx" | "tsc" | "tsgo" => parse_tsc_output(stdout, stderr, file),
1567        "biome" => parse_biome_output(stdout, stderr, file),
1568        "pyright" => parse_pyright_output(stdout, file),
1569        "ruff" => parse_ruff_output(stdout, stderr, file),
1570        "cargo" => parse_cargo_output(stdout, stderr, file),
1571        "go" => parse_go_vet_output(stderr, file),
1572        "staticcheck" => parse_staticcheck_output(stdout, stderr, file),
1573        _ => Vec::new(),
1574    }
1575}
1576
1577fn checker_executable_name(checker: &str) -> String {
1578    let name = checker
1579        .rsplit(['/', '\\'])
1580        .next()
1581        .filter(|name| !name.is_empty())
1582        .unwrap_or(checker)
1583        .to_ascii_lowercase();
1584
1585    for suffix in [".exe", ".cmd", ".bat", ".ps1"] {
1586        if let Some(stripped) = name.strip_suffix(suffix) {
1587            return stripped.to_string();
1588        }
1589    }
1590
1591    name
1592}
1593
1594fn normalize_path_for_compare(path: &str) -> String {
1595    path.trim_start_matches("file://")
1596        .replace('\\', "/")
1597        .trim_start_matches("./")
1598        .to_string()
1599}
1600
1601fn diagnostic_path_matches(file: &Path, diagnostic_file: &str) -> bool {
1602    if diagnostic_file.is_empty() {
1603        return true;
1604    }
1605
1606    let file_str = normalize_path_for_compare(&file.to_string_lossy());
1607    let diagnostic_str = normalize_path_for_compare(diagnostic_file);
1608    file_str == diagnostic_str
1609        || file_str.ends_with(&diagnostic_str)
1610        || diagnostic_str.ends_with(&file_str)
1611}
1612
1613fn line_column_for_byte_offset(source: &str, offset: usize) -> (u32, u32) {
1614    let mut line = 1u32;
1615    let mut column = 1u32;
1616    for (idx, ch) in source.char_indices() {
1617        if idx >= offset {
1618            break;
1619        }
1620        if ch == '\n' {
1621            line += 1;
1622            column = 1;
1623        } else {
1624            column += 1;
1625        }
1626    }
1627    (line, column)
1628}
1629
1630fn json_string_at<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a str> {
1631    let mut current = value;
1632    for key in path {
1633        current = current.get(*key)?;
1634    }
1635    current.as_str()
1636}
1637
1638fn json_u32_at(value: &serde_json::Value, path: &[&str]) -> Option<u32> {
1639    let mut current = value;
1640    for key in path {
1641        current = current.get(*key)?;
1642    }
1643    current.as_u64().map(|n| n as u32)
1644}
1645
1646fn json_location_path(value: &serde_json::Value) -> Option<&str> {
1647    json_string_at(value, &["location", "path", "file"])
1648        .or_else(|| json_string_at(value, &["location", "path"]))
1649        .or_else(|| json_string_at(value, &["filename"]))
1650        .or_else(|| json_string_at(value, &["file"]))
1651}
1652
1653fn diagnostic_message(value: &serde_json::Value) -> String {
1654    json_string_at(value, &["description"])
1655        .or_else(|| json_string_at(value, &["message"]))
1656        .or_else(|| json_string_at(value, &["text"]))
1657        .or_else(|| json_string_at(value, &["category"]))
1658        .unwrap_or("unknown error")
1659        .to_string()
1660}
1661
1662/// Parse tsc output lines like: `path(line,col): error TSxxxx: message`
1663fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1664    let mut errors = Vec::new();
1665    let file_str = file.to_string_lossy();
1666    // tsc writes diagnostics to stdout (with --pretty false)
1667    let combined = format!("{}{}", stdout, stderr);
1668    for line in combined.lines() {
1669        // Format: path(line,col): severity TSxxxx: message
1670        // or: path(line,col): severity: message
1671        if let Some((loc, rest)) = line.split_once("): ") {
1672            // Check if this error is for our file (compare filename part)
1673            let file_part = loc.split('(').next().unwrap_or("");
1674            if !file_str.ends_with(file_part)
1675                && !file_part.ends_with(&*file_str)
1676                && file_part != &*file_str
1677            {
1678                continue;
1679            }
1680
1681            // Parse (line,col) from the location part
1682            let coords = loc.split('(').last().unwrap_or("");
1683            let parts: Vec<&str> = coords.split(',').collect();
1684            let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
1685            let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1686
1687            // Parse severity and message
1688            let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
1689                ("error".to_string(), msg.to_string())
1690            } else if let Some(msg) = rest.strip_prefix("warning ") {
1691                ("warning".to_string(), msg.to_string())
1692            } else {
1693                ("error".to_string(), rest.to_string())
1694            };
1695
1696            errors.push(ValidationError {
1697                line: line_num,
1698                column: col_num,
1699                message,
1700                severity,
1701            });
1702        }
1703    }
1704    errors
1705}
1706
1707fn parse_biome_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1708    let mut errors = Vec::new();
1709    for output in [stdout, stderr] {
1710        let trimmed = output.trim();
1711        if trimmed.is_empty() {
1712            continue;
1713        }
1714        if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
1715            parse_biome_json_value(&json, file, &mut errors);
1716        }
1717    }
1718    errors
1719}
1720
1721fn parse_biome_json_value(
1722    json: &serde_json::Value,
1723    file: &Path,
1724    errors: &mut Vec<ValidationError>,
1725) {
1726    let diagnostics: Vec<&serde_json::Value> = if let Some(diags) = json
1727        .get("diagnostics")
1728        .and_then(|diagnostics| diagnostics.as_array())
1729    {
1730        diags.iter().collect()
1731    } else if let Some(diags) = json.as_array() {
1732        diags.iter().collect()
1733    } else {
1734        Vec::new()
1735    };
1736
1737    let source = std::fs::read_to_string(file).ok();
1738    for diag in diagnostics {
1739        if let Some(diag_file) = json_location_path(diag) {
1740            if !diagnostic_path_matches(file, diag_file) {
1741                continue;
1742            }
1743        }
1744
1745        let (line, column) = biome_line_column(diag, source.as_deref());
1746        errors.push(ValidationError {
1747            line,
1748            column,
1749            message: diagnostic_message(diag),
1750            severity: diag
1751                .get("severity")
1752                .and_then(|severity| severity.as_str())
1753                .unwrap_or("error")
1754                .to_lowercase(),
1755        });
1756    }
1757}
1758
1759fn biome_line_column(diag: &serde_json::Value, source: Option<&str>) -> (u32, u32) {
1760    if let Some(line) =
1761        json_u32_at(diag, &["location", "line"]).or_else(|| json_u32_at(diag, &["line"]))
1762    {
1763        let column = json_u32_at(diag, &["location", "column"])
1764            .or_else(|| json_u32_at(diag, &["column"]))
1765            .unwrap_or(0);
1766        return (line, column);
1767    }
1768
1769    let offset = diag
1770        .get("location")
1771        .and_then(|location| location.get("span"))
1772        .and_then(|span| span.as_array())
1773        .and_then(|span| span.first())
1774        .and_then(|offset| offset.as_u64())
1775        .map(|offset| offset as usize);
1776
1777    match (source, offset) {
1778        (Some(source), Some(offset)) => line_column_for_byte_offset(source, offset),
1779        _ => (0, 0),
1780    }
1781}
1782
1783fn parse_ruff_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1784    let mut errors = Vec::new();
1785    for output in [stdout, stderr] {
1786        let trimmed = output.trim();
1787        if trimmed.is_empty() {
1788            continue;
1789        }
1790        if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
1791            parse_ruff_json_value(&json, file, &mut errors);
1792        }
1793    }
1794    errors
1795}
1796
1797fn parse_ruff_json_value(json: &serde_json::Value, file: &Path, errors: &mut Vec<ValidationError>) {
1798    let diagnostics: Vec<&serde_json::Value> = if let Some(diags) = json.as_array() {
1799        diags.iter().collect()
1800    } else if let Some(diags) = json.get("diagnostics").and_then(|d| d.as_array()) {
1801        diags.iter().collect()
1802    } else {
1803        Vec::new()
1804    };
1805
1806    for diag in diagnostics {
1807        let diag_file = diag
1808            .get("filename")
1809            .and_then(|filename| filename.as_str())
1810            .unwrap_or("");
1811        if !diagnostic_path_matches(file, diag_file) {
1812            continue;
1813        }
1814
1815        let message = match (
1816            diag.get("code").and_then(|code| code.as_str()),
1817            diag.get("message").and_then(|message| message.as_str()),
1818        ) {
1819            (Some(code), Some(message)) => format!("{code}: {message}"),
1820            (None, Some(message)) => message.to_string(),
1821            (Some(code), None) => code.to_string(),
1822            (None, None) => "unknown error".to_string(),
1823        };
1824
1825        errors.push(ValidationError {
1826            line: json_u32_at(diag, &["location", "row"])
1827                .or_else(|| json_u32_at(diag, &["location", "line"]))
1828                .unwrap_or(0),
1829            column: json_u32_at(diag, &["location", "column"]).unwrap_or(0),
1830            message,
1831            severity: diag
1832                .get("severity")
1833                .and_then(|severity| severity.as_str())
1834                .unwrap_or("error")
1835                .to_lowercase(),
1836        });
1837    }
1838}
1839
1840/// Parse pyright JSON output.
1841fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
1842    let mut errors = Vec::new();
1843    // pyright --outputjson emits JSON with generalDiagnostics array
1844    if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
1845        if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
1846            for diag in diags {
1847                // Filter to our file
1848                let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
1849                if !diagnostic_path_matches(file, diag_file) {
1850                    continue;
1851                }
1852
1853                let line_num = diag
1854                    .get("range")
1855                    .and_then(|r| r.get("start"))
1856                    .and_then(|s| s.get("line"))
1857                    .and_then(|l| l.as_u64())
1858                    .unwrap_or(0) as u32;
1859                let col_num = diag
1860                    .get("range")
1861                    .and_then(|r| r.get("start"))
1862                    .and_then(|s| s.get("character"))
1863                    .and_then(|c| c.as_u64())
1864                    .unwrap_or(0) as u32;
1865                let message = diag
1866                    .get("message")
1867                    .and_then(|m| m.as_str())
1868                    .unwrap_or("unknown error")
1869                    .to_string();
1870                let severity = diag
1871                    .get("severity")
1872                    .and_then(|s| s.as_str())
1873                    .unwrap_or("error")
1874                    .to_lowercase();
1875
1876                errors.push(ValidationError {
1877                    line: line_num + 1,  // pyright uses 0-indexed lines
1878                    column: col_num + 1, // pyright uses 0-indexed columns
1879                    message,
1880                    severity,
1881                });
1882            }
1883        }
1884    }
1885    errors
1886}
1887
1888/// Parse cargo check JSON output, filtering to errors in the target file.
1889fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
1890    let mut errors = Vec::new();
1891    let file_str = file.to_string_lossy();
1892
1893    for line in stdout.lines() {
1894        if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
1895            if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
1896                continue;
1897            }
1898            let message_obj = match msg.get("message") {
1899                Some(m) => m,
1900                None => continue,
1901            };
1902
1903            let level = message_obj
1904                .get("level")
1905                .and_then(|l| l.as_str())
1906                .unwrap_or("error");
1907
1908            // Only include errors and warnings, skip notes/help
1909            if level != "error" && level != "warning" {
1910                continue;
1911            }
1912
1913            let text = message_obj
1914                .get("message")
1915                .and_then(|m| m.as_str())
1916                .unwrap_or("unknown error")
1917                .to_string();
1918
1919            // Find the primary span for our file
1920            if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
1921                for span in spans {
1922                    let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
1923                    let is_primary = span
1924                        .get("is_primary")
1925                        .and_then(|p| p.as_bool())
1926                        .unwrap_or(false);
1927
1928                    if !is_primary {
1929                        continue;
1930                    }
1931
1932                    // Filter to our file
1933                    if !file_str.ends_with(span_file)
1934                        && !span_file.ends_with(&*file_str)
1935                        && span_file != &*file_str
1936                    {
1937                        continue;
1938                    }
1939
1940                    let line_num =
1941                        span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
1942                    let col_num = span
1943                        .get("column_start")
1944                        .and_then(|c| c.as_u64())
1945                        .unwrap_or(0) as u32;
1946
1947                    errors.push(ValidationError {
1948                        line: line_num,
1949                        column: col_num,
1950                        message: text.clone(),
1951                        severity: level.to_string(),
1952                    });
1953                }
1954            }
1955        }
1956    }
1957    errors
1958}
1959
1960/// Parse go vet output lines like: `path:line:col: message`
1961fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
1962    let mut errors = Vec::new();
1963    let pattern =
1964        regex::Regex::new(r"^(?P<file>.+?):(?P<line>\d+)(?::(?P<col>\d+))?:\s*(?P<message>.*)$")
1965            .expect("valid go vet diagnostic regex");
1966
1967    for line in stderr.lines() {
1968        let Some(captures) = pattern.captures(line) else {
1969            continue;
1970        };
1971
1972        let err_file = captures
1973            .name("file")
1974            .map(|m| m.as_str())
1975            .unwrap_or("")
1976            .trim();
1977        if !diagnostic_path_matches(file, err_file) {
1978            continue;
1979        }
1980
1981        errors.push(ValidationError {
1982            line: captures
1983                .name("line")
1984                .and_then(|m| m.as_str().parse().ok())
1985                .unwrap_or(0),
1986            column: captures
1987                .name("col")
1988                .and_then(|m| m.as_str().parse().ok())
1989                .unwrap_or(0),
1990            message: captures
1991                .name("message")
1992                .map(|m| m.as_str().trim().to_string())
1993                .unwrap_or_else(|| "unknown error".to_string()),
1994            severity: "error".to_string(),
1995        });
1996    }
1997    errors
1998}
1999
2000fn parse_staticcheck_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
2001    let combined = format!("{}\n{}", stdout, stderr);
2002    let trimmed = combined.trim();
2003    if trimmed.is_empty() {
2004        return Vec::new();
2005    }
2006
2007    let mut errors = Vec::new();
2008    if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
2009        parse_staticcheck_json_value(&json, file, &mut errors);
2010        return errors;
2011    }
2012
2013    for line in trimmed.lines() {
2014        let line = line.trim();
2015        if line.is_empty() {
2016            continue;
2017        }
2018        if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
2019            parse_staticcheck_json_value(&json, file, &mut errors);
2020        }
2021    }
2022
2023    errors
2024}
2025
2026fn parse_staticcheck_json_value(
2027    json: &serde_json::Value,
2028    file: &Path,
2029    errors: &mut Vec<ValidationError>,
2030) {
2031    if let Some(diags) = json.as_array() {
2032        for diag in diags {
2033            parse_staticcheck_diag(diag, file, errors);
2034        }
2035    } else if let Some(diags) = json.get("diagnostics").and_then(|d| d.as_array()) {
2036        for diag in diags {
2037            parse_staticcheck_diag(diag, file, errors);
2038        }
2039    } else if let Some(diags) = json.get("issues").and_then(|d| d.as_array()) {
2040        for diag in diags {
2041            parse_staticcheck_diag(diag, file, errors);
2042        }
2043    } else {
2044        parse_staticcheck_diag(json, file, errors);
2045    }
2046}
2047
2048fn parse_staticcheck_diag(
2049    diag: &serde_json::Value,
2050    file: &Path,
2051    errors: &mut Vec<ValidationError>,
2052) {
2053    let diag_file = json_string_at(diag, &["location", "file"])
2054        .or_else(|| json_string_at(diag, &["file"]))
2055        .unwrap_or("");
2056    if !diagnostic_path_matches(file, diag_file) {
2057        return;
2058    }
2059
2060    let message = match (
2061        diag.get("code").and_then(|code| code.as_str()),
2062        diag.get("message").and_then(|message| message.as_str()),
2063    ) {
2064        (Some(code), Some(message)) => format!("{code}: {message}"),
2065        (None, Some(message)) => message.to_string(),
2066        (Some(code), None) => code.to_string(),
2067        (None, None) => "unknown error".to_string(),
2068    };
2069
2070    errors.push(ValidationError {
2071        line: json_u32_at(diag, &["location", "line"])
2072            .or_else(|| json_u32_at(diag, &["line"]))
2073            .unwrap_or(0),
2074        column: json_u32_at(diag, &["location", "column"])
2075            .or_else(|| json_u32_at(diag, &["column"]))
2076            .unwrap_or(0),
2077        message,
2078        severity: diag
2079            .get("severity")
2080            .and_then(|severity| severity.as_str())
2081            .unwrap_or("error")
2082            .to_lowercase(),
2083    });
2084}
2085
2086fn output_tail_summary(stdout: &str, stderr: &str, truncated: bool) -> String {
2087    let mut parts = Vec::new();
2088    if let Some(tail) = short_output_tail(stderr) {
2089        parts.push(format!("stderr: {tail}"));
2090    }
2091    if let Some(tail) = short_output_tail(stdout) {
2092        parts.push(format!("stdout: {tail}"));
2093    }
2094    if truncated {
2095        parts.push("output truncated".to_string());
2096    }
2097
2098    if parts.is_empty() {
2099        "no output".to_string()
2100    } else {
2101        parts.join("; ")
2102    }
2103}
2104
2105fn short_output_tail(output: &str) -> Option<String> {
2106    let trimmed = output.trim();
2107    if trimmed.is_empty() {
2108        return None;
2109    }
2110
2111    let mut lines: Vec<&str> = trimmed.lines().rev().take(3).collect();
2112    lines.reverse();
2113    let mut tail = lines.join(" | ");
2114    const MAX_TAIL_CHARS: usize = 500;
2115    if tail.len() > MAX_TAIL_CHARS {
2116        let start = tail.len().saturating_sub(MAX_TAIL_CHARS);
2117        tail = format!("…{}", &tail[start..]);
2118    }
2119    Some(tail)
2120}
2121
2122/// Run the project's type checker and return structured validation errors.
2123///
2124/// Returns `(errors, skip_reason)`:
2125/// - `(errors, None)` — checker ran, errors may be empty (= valid code)
2126/// - `([], Some(reason))` — checker was skipped
2127///
2128/// Skip reasons: `"unsupported_language"`, `"no_checker_configured"`,
2129/// `"checker_not_installed"`, `"timeout"`, `"error"`
2130pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
2131    let lang = match detect_language(path) {
2132        Some(l) => l,
2133        None => {
2134            log::debug!(
2135                "validate: {} (skipped: unsupported_language)",
2136                path.display()
2137            );
2138            return (Vec::new(), Some("unsupported_language".to_string()));
2139        }
2140    };
2141    if !has_checker_support(lang) {
2142        log::debug!(
2143            "validate: {} (skipped: unsupported_language)",
2144            path.display()
2145        );
2146        return (Vec::new(), Some("unsupported_language".to_string()));
2147    }
2148
2149    let (cmd, args) = match detect_checker_for_path(path, lang, config) {
2150        ToolDetection::Found(cmd, args) => (cmd, args),
2151        ToolDetection::NotConfigured => {
2152            log::debug!(
2153                "validate: {} (skipped: no_checker_configured)",
2154                path.display()
2155            );
2156            return (Vec::new(), Some("no_checker_configured".to_string()));
2157        }
2158        ToolDetection::NotInstalled { tool } => {
2159            crate::slog_warn!(
2160                "validate: {} (skipped: checker_not_installed: {})",
2161                path.display(),
2162                tool
2163            );
2164            return (Vec::new(), Some("checker_not_installed".to_string()));
2165        }
2166    };
2167
2168    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
2169
2170    // Type checkers may need to run from the project root
2171    let working_dir = config.project_root.as_deref();
2172
2173    match run_external_tool_capture(
2174        &cmd,
2175        &arg_refs,
2176        working_dir,
2177        config.type_checker_timeout_secs,
2178    ) {
2179        Ok(result) => {
2180            let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
2181            if result.exit_code != 0 && errors.is_empty() {
2182                let summary = output_tail_summary(&result.stdout, &result.stderr, result.truncated);
2183                log::debug!(
2184                    "validate: {} (skipped: error: checker exited {} with {})",
2185                    path.display(),
2186                    result.exit_code,
2187                    summary
2188                );
2189                return (Vec::new(), Some("error".to_string()));
2190            }
2191            log::debug!(
2192                "validate: {} ({}, {} errors)",
2193                path.display(),
2194                cmd,
2195                errors.len()
2196            );
2197            (errors, None)
2198        }
2199        Err(FormatError::Timeout { .. }) => {
2200            crate::slog_error!("validate: {} (skipped: timeout)", path.display());
2201            (Vec::new(), Some("timeout".to_string()))
2202        }
2203        Err(FormatError::NotFound { .. }) => {
2204            crate::slog_warn!(
2205                "validate: {} (skipped: checker_not_installed)",
2206                path.display()
2207            );
2208            (Vec::new(), Some("checker_not_installed".to_string()))
2209        }
2210        Err(FormatError::Failed { stderr, .. }) => {
2211            log::debug!(
2212                "validate: {} (skipped: error: {})",
2213                path.display(),
2214                stderr.lines().next().unwrap_or("unknown")
2215            );
2216            (Vec::new(), Some("error".to_string()))
2217        }
2218        Err(FormatError::UnsupportedLanguage) => {
2219            log::debug!(
2220                "validate: {} (skipped: unsupported_language)",
2221                path.display()
2222            );
2223            (Vec::new(), Some("unsupported_language".to_string()))
2224        }
2225    }
2226}
2227
2228#[cfg(test)]
2229mod tests {
2230    use super::*;
2231    use std::fs;
2232    use std::io::Write;
2233    use std::sync::{Mutex, MutexGuard, OnceLock};
2234
2235    /// Serializes tests that mutate the global TOOL_RESOLUTION_CACHE /
2236    /// TOOL_AVAILABILITY_CACHE. Cargo runs tests in parallel by default, and
2237    /// `clear_tool_cache()` from one test would otherwise wipe cached entries
2238    /// that another test had just written, causing flaky CI failures (the
2239    /// `resolve_tool_caches_negative_result_until_clear` failure on Linux
2240    /// runners had exactly this shape).
2241    fn tool_cache_test_lock() -> MutexGuard<'static, ()> {
2242        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2243        let mutex = LOCK.get_or_init(|| Mutex::new(()));
2244        // Recover from poisoning so a panic in one test doesn't permanently
2245        // wedge the rest of the suite.
2246        match mutex.lock() {
2247            Ok(guard) => guard,
2248            Err(poisoned) => poisoned.into_inner(),
2249        }
2250    }
2251
2252    #[test]
2253    fn run_external_tool_not_found() {
2254        let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
2255        assert!(result.is_err());
2256        match result.unwrap_err() {
2257            FormatError::NotFound { tool } => {
2258                assert_eq!(tool, "__nonexistent_tool_xyz__");
2259            }
2260            other => panic!("expected NotFound, got: {:?}", other),
2261        }
2262    }
2263
2264    #[test]
2265    fn run_external_tool_timeout_kills_subprocess() {
2266        // Use `sleep 60` as a long-running process, timeout after 1 second
2267        let result = run_external_tool("sleep", &["60"], None, 1);
2268        assert!(result.is_err());
2269        match result.unwrap_err() {
2270            FormatError::Timeout { tool, timeout_secs } => {
2271                assert_eq!(tool, "sleep");
2272                assert_eq!(timeout_secs, 1);
2273            }
2274            other => panic!("expected Timeout, got: {:?}", other),
2275        }
2276    }
2277
2278    #[test]
2279    fn run_external_tool_success() {
2280        let result = run_external_tool("echo", &["hello"], None, 5);
2281        assert!(result.is_ok());
2282        let res = result.unwrap();
2283        assert_eq!(res.exit_code, 0);
2284        assert!(res.stdout.contains("hello"));
2285    }
2286
2287    #[cfg(unix)]
2288    #[test]
2289    fn format_helper_handles_large_stderr_without_deadlock() {
2290        let start = Instant::now();
2291        let result = run_external_tool_capture(
2292            "sh",
2293            &[
2294                "-c",
2295                "i=0; while [ $i -lt 1024 ]; do printf '%1024s\\n' x >&2; i=$((i+1)); done",
2296            ],
2297            None,
2298            2,
2299        )
2300        .expect("large stderr command should complete");
2301
2302        assert_eq!(result.exit_code, 0);
2303        assert!(
2304            result.stderr.len() >= 1024 * 1024,
2305            "expected full stderr capture, got {} bytes",
2306            result.stderr.len()
2307        );
2308        assert!(start.elapsed() < Duration::from_secs(2));
2309    }
2310
2311    #[test]
2312    fn run_external_tool_nonzero_exit() {
2313        // `false` always exits with code 1
2314        let result = run_external_tool("false", &[], None, 5);
2315        assert!(result.is_err());
2316        match result.unwrap_err() {
2317            FormatError::Failed { tool, .. } => {
2318                assert_eq!(tool, "false");
2319            }
2320            other => panic!("expected Failed, got: {:?}", other),
2321        }
2322    }
2323
2324    #[test]
2325    fn auto_format_unsupported_language() {
2326        let dir = tempfile::tempdir().unwrap();
2327        let path = dir.path().join("file.txt");
2328        fs::write(&path, "hello").unwrap();
2329
2330        let config = Config::default();
2331        let (formatted, reason) = auto_format(&path, &config);
2332        assert!(!formatted);
2333        assert_eq!(reason.as_deref(), Some("unsupported_language"));
2334    }
2335
2336    #[test]
2337    fn detect_formatter_rust_when_rustfmt_available() {
2338        let dir = tempfile::tempdir().unwrap();
2339        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2340        let path = dir.path().join("test.rs");
2341        let config = Config {
2342            project_root: Some(dir.path().to_path_buf()),
2343            ..Config::default()
2344        };
2345        let result = detect_formatter(&path, LangId::Rust, &config);
2346        if resolve_tool("rustfmt", config.project_root.as_deref()).is_some() {
2347            let (cmd, args) = result.unwrap();
2348            // Windows resolves to `rustfmt.exe` and may include a full path
2349            // (e.g. `C:\Users\...\.cargo\bin\rustfmt.exe`). Just require the
2350            // command stem to be `rustfmt`.
2351            let stem = std::path::Path::new(&cmd)
2352                .file_stem()
2353                .and_then(|s| s.to_str())
2354                .unwrap_or("");
2355            assert_eq!(stem, "rustfmt", "expected rustfmt, got {cmd}");
2356            assert!(args.iter().any(|a| a.ends_with("test.rs")));
2357        } else {
2358            assert!(result.is_none());
2359        }
2360    }
2361
2362    #[test]
2363    fn detect_formatter_go_mapping() {
2364        let dir = tempfile::tempdir().unwrap();
2365        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
2366        let path = dir.path().join("main.go");
2367        let config = Config {
2368            project_root: Some(dir.path().to_path_buf()),
2369            ..Config::default()
2370        };
2371        let result = detect_formatter(&path, LangId::Go, &config);
2372        if resolve_tool("goimports", config.project_root.as_deref()).is_some() {
2373            let (cmd, args) = result.unwrap();
2374            assert_eq!(
2375                std::path::Path::new(&cmd)
2376                    .file_stem()
2377                    .and_then(|s| s.to_str())
2378                    .unwrap_or(""),
2379                "goimports",
2380                "expected goimports, got {cmd}"
2381            );
2382            assert!(args.contains(&"-w".to_string()));
2383        } else if resolve_tool("gofmt", config.project_root.as_deref()).is_some() {
2384            let (cmd, args) = result.unwrap();
2385            assert_eq!(
2386                std::path::Path::new(&cmd)
2387                    .file_stem()
2388                    .and_then(|s| s.to_str())
2389                    .unwrap_or(""),
2390                "gofmt",
2391                "expected gofmt, got {cmd}"
2392            );
2393            assert!(args.contains(&"-w".to_string()));
2394        } else {
2395            assert!(result.is_none());
2396        }
2397    }
2398
2399    #[test]
2400    fn detect_formatter_python_mapping() {
2401        let dir = tempfile::tempdir().unwrap();
2402        fs::write(dir.path().join("ruff.toml"), "").unwrap();
2403        let path = dir.path().join("main.py");
2404        let config = Config {
2405            project_root: Some(dir.path().to_path_buf()),
2406            ..Config::default()
2407        };
2408        let result = detect_formatter(&path, LangId::Python, &config);
2409        if ruff_format_available(config.project_root.as_deref()) {
2410            let (cmd, args) = result.unwrap();
2411            assert_eq!(
2412                std::path::Path::new(&cmd)
2413                    .file_stem()
2414                    .and_then(|s| s.to_str())
2415                    .unwrap_or(""),
2416                "ruff",
2417                "expected ruff, got {cmd}"
2418            );
2419            assert!(args.contains(&"format".to_string()));
2420        } else {
2421            assert!(result.is_none());
2422        }
2423    }
2424
2425    #[test]
2426    fn detect_formatter_no_config_returns_none() {
2427        let path = Path::new("test.ts");
2428        let result = detect_formatter(path, LangId::TypeScript, &Config::default());
2429        assert!(
2430            result.is_none(),
2431            "expected no formatter without project config"
2432        );
2433    }
2434
2435    #[cfg(unix)]
2436    #[test]
2437    fn detect_formatter_oxfmt_config_for_typescript_projects() {
2438        let _guard = tool_cache_test_lock();
2439        clear_tool_cache();
2440        let dir = tempfile::tempdir().unwrap();
2441        fs::write(dir.path().join(".oxfmtrc.json"), "{}\n").unwrap();
2442        let bin_dir = dir.path().join("node_modules").join(".bin");
2443        fs::create_dir_all(&bin_dir).unwrap();
2444        let fake = bin_dir.join("oxfmt");
2445        fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2446        use std::os::unix::fs::PermissionsExt;
2447        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2448
2449        let path = dir.path().join("src/app.ts");
2450        let config = Config {
2451            project_root: Some(dir.path().to_path_buf()),
2452            ..Config::default()
2453        };
2454
2455        let (cmd, args) = detect_formatter(&path, LangId::TypeScript, &config).unwrap();
2456        assert!(cmd.ends_with("oxfmt"), "expected oxfmt, got {cmd}");
2457        assert_eq!(args[0], "--write");
2458        assert!(args.iter().any(|arg| arg.ends_with("src/app.ts")));
2459    }
2460
2461    // Unix-only: `resolve_tool_uncached` checks `node_modules/.bin/<name>`
2462    // without trying Windows extensions (.cmd/.exe/.bat). Writing
2463    // `biome.cmd` would not be found by the resolver. A future product
2464    // fix could extend resolve_tool to honor PATHEXT; for now this test
2465    // focuses on the explicit-override semantics on Unix.
2466    #[cfg(unix)]
2467    #[test]
2468    fn detect_formatter_explicit_override() {
2469        // Create a temp dir with a fake node_modules/.bin/biome so resolve_tool finds it
2470        let dir = tempfile::tempdir().unwrap();
2471        let bin_dir = dir.path().join("node_modules").join(".bin");
2472        fs::create_dir_all(&bin_dir).unwrap();
2473        use std::os::unix::fs::PermissionsExt;
2474        let fake = bin_dir.join("biome");
2475        fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2476        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2477
2478        let path = Path::new("test.ts");
2479        let mut config = Config {
2480            project_root: Some(dir.path().to_path_buf()),
2481            ..Config::default()
2482        };
2483        config
2484            .formatter
2485            .insert("typescript".to_string(), "biome".to_string());
2486        let result = detect_formatter(path, LangId::TypeScript, &config);
2487        let (cmd, args) = result.unwrap();
2488        assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
2489        assert!(args.contains(&"format".to_string()));
2490        assert!(args.contains(&"--write".to_string()));
2491    }
2492
2493    #[cfg(unix)]
2494    #[test]
2495    fn detect_formatter_explicit_oxfmt_override() {
2496        let _guard = tool_cache_test_lock();
2497        clear_tool_cache();
2498        let dir = tempfile::tempdir().unwrap();
2499        let bin_dir = dir.path().join("node_modules").join(".bin");
2500        fs::create_dir_all(&bin_dir).unwrap();
2501        use std::os::unix::fs::PermissionsExt;
2502        let fake = bin_dir.join("oxfmt");
2503        fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2504        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2505
2506        let path = Path::new("test.ts");
2507        let mut config = Config {
2508            project_root: Some(dir.path().to_path_buf()),
2509            ..Config::default()
2510        };
2511        config
2512            .formatter
2513            .insert("typescript".to_string(), "oxfmt".to_string());
2514
2515        let (cmd, args) = detect_formatter(path, LangId::TypeScript, &config).unwrap();
2516        assert!(cmd.contains("oxfmt"), "expected oxfmt in cmd, got: {cmd}");
2517        assert_eq!(args, vec!["--write".to_string(), "test.ts".to_string()]);
2518    }
2519
2520    #[test]
2521    fn resolve_tool_caches_positive_result_until_clear() {
2522        let _guard = tool_cache_test_lock();
2523        clear_tool_cache();
2524        let dir = tempfile::tempdir().unwrap();
2525        let bin_dir = dir.path().join("node_modules").join(".bin");
2526        fs::create_dir_all(&bin_dir).unwrap();
2527        let tool = bin_dir.join("aft-cache-hit-tool");
2528        fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
2529
2530        let first = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
2531        assert_eq!(first.as_deref(), Some(tool.to_string_lossy().as_ref()));
2532
2533        fs::remove_file(&tool).unwrap();
2534        let cached = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
2535        assert_eq!(cached, first);
2536
2537        clear_tool_cache();
2538        assert!(resolve_tool("aft-cache-hit-tool", Some(dir.path())).is_none());
2539    }
2540
2541    #[test]
2542    fn resolve_tool_caches_negative_result_until_clear() {
2543        let _guard = tool_cache_test_lock();
2544        clear_tool_cache();
2545        let dir = tempfile::tempdir().unwrap();
2546        let bin_dir = dir.path().join("node_modules").join(".bin");
2547        let tool = bin_dir.join("aft-cache-miss-tool");
2548
2549        assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
2550
2551        fs::create_dir_all(&bin_dir).unwrap();
2552        fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
2553        assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
2554
2555        clear_tool_cache();
2556        assert_eq!(
2557            resolve_tool("aft-cache-miss-tool", Some(dir.path())).as_deref(),
2558            Some(tool.to_string_lossy().as_ref())
2559        );
2560    }
2561
2562    #[test]
2563    fn auto_format_happy_path_rustfmt() {
2564        if resolve_tool("rustfmt", None).is_none() {
2565            crate::slog_warn!("skipping: rustfmt not available");
2566            return;
2567        }
2568
2569        let dir = tempfile::tempdir().unwrap();
2570        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2571        let path = dir.path().join("test.rs");
2572
2573        let mut f = fs::File::create(&path).unwrap();
2574        writeln!(f, "fn    main()   {{  println!(\"hello\");  }}").unwrap();
2575        drop(f);
2576
2577        let config = Config {
2578            project_root: Some(dir.path().to_path_buf()),
2579            ..Config::default()
2580        };
2581        let (formatted, reason) = auto_format(&path, &config);
2582        assert!(formatted, "expected formatting to succeed");
2583        assert!(reason.is_none());
2584
2585        let content = fs::read_to_string(&path).unwrap();
2586        assert!(
2587            !content.contains("fn    main"),
2588            "expected rustfmt to fix spacing"
2589        );
2590    }
2591
2592    #[test]
2593    fn formatter_excluded_path_detects_biome_messages() {
2594        // Real biome 1.x output when invoked on a path outside files.includes.
2595        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";
2596        assert!(
2597            formatter_excluded_path(stderr),
2598            "expected biome exclusion stderr to be detected"
2599        );
2600    }
2601
2602    #[test]
2603    fn formatter_excluded_path_detects_prettier_messages() {
2604        // Real prettier output when given a glob/path that resolves to nothing
2605        // it's allowed to format (after .prettierignore filtering).
2606        let stderr = "[error] No files matching the pattern were found: \"src/scratch.ts\".\n";
2607        assert!(
2608            formatter_excluded_path(stderr),
2609            "expected prettier exclusion stderr to be detected"
2610        );
2611    }
2612
2613    #[test]
2614    fn formatter_excluded_path_detects_oxfmt_messages() {
2615        assert!(formatter_excluded_path(
2616            "Expected at least one target file. All matched files may have been excluded by ignore rules."
2617        ));
2618        assert!(formatter_excluded_path(
2619            "No files found matching the given patterns."
2620        ));
2621    }
2622
2623    #[test]
2624    fn formatter_excluded_path_detects_ruff_messages() {
2625        // Real ruff output when invoked outside its [tool.ruff] scope.
2626        let stderr = "warning: No Python files found under the given path(s).\n";
2627        assert!(
2628            formatter_excluded_path(stderr),
2629            "expected ruff exclusion stderr to be detected"
2630        );
2631    }
2632
2633    #[test]
2634    fn formatter_excluded_path_is_case_insensitive() {
2635        assert!(formatter_excluded_path("NO FILES WERE PROCESSED"));
2636        assert!(formatter_excluded_path("Ignored By The Configuration"));
2637        assert!(formatter_excluded_path("EXPECTED AT LEAST ONE TARGET FILE"));
2638    }
2639
2640    #[test]
2641    fn formatter_excluded_path_rejects_real_errors() {
2642        // Counter-cases: actual formatter errors must NOT be treated as
2643        // exclusion. This guards against the detection being too greedy.
2644        assert!(!formatter_excluded_path(""));
2645        assert!(!formatter_excluded_path("syntax error: unexpected token"));
2646        assert!(!formatter_excluded_path("formatter crashed: out of memory"));
2647        assert!(!formatter_excluded_path(
2648            "permission denied: /readonly/file"
2649        ));
2650        assert!(!formatter_excluded_path(
2651            "biome internal error: please report"
2652        ));
2653    }
2654
2655    #[test]
2656    fn parse_tsc_output_basic() {
2657        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";
2658        let file = Path::new("src/app.ts");
2659        let errors = parse_tsc_output(stdout, "", file);
2660        assert_eq!(errors.len(), 2);
2661        assert_eq!(errors[0].line, 10);
2662        assert_eq!(errors[0].column, 5);
2663        assert_eq!(errors[0].severity, "error");
2664        assert!(errors[0].message.contains("TS2322"));
2665        assert_eq!(errors[1].line, 20);
2666    }
2667
2668    #[test]
2669    fn parse_tsc_output_filters_other_files() {
2670        let stdout =
2671            "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
2672        let file = Path::new("src/app.ts");
2673        let errors = parse_tsc_output(stdout, "", file);
2674        assert_eq!(errors.len(), 1);
2675        assert_eq!(errors[0].line, 5);
2676    }
2677
2678    #[test]
2679    fn parse_cargo_output_basic() {
2680        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}]}}"#;
2681        let file = Path::new("src/main.rs");
2682        let errors = parse_cargo_output(json_line, "", file);
2683        assert_eq!(errors.len(), 1);
2684        assert_eq!(errors[0].line, 10);
2685        assert_eq!(errors[0].column, 5);
2686        assert_eq!(errors[0].severity, "error");
2687        assert!(errors[0].message.contains("mismatched types"));
2688    }
2689
2690    #[test]
2691    fn parse_cargo_output_skips_notes() {
2692        // Notes and help messages should be filtered out
2693        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}]}}"#;
2694        let file = Path::new("src/main.rs");
2695        let errors = parse_cargo_output(json_line, "", file);
2696        assert_eq!(errors.len(), 0);
2697    }
2698
2699    #[test]
2700    fn parse_cargo_output_filters_other_files() {
2701        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}]}}"#;
2702        let file = Path::new("src/main.rs");
2703        let errors = parse_cargo_output(json_line, "", file);
2704        assert_eq!(errors.len(), 0);
2705    }
2706
2707    #[test]
2708    fn parse_go_vet_output_basic() {
2709        let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
2710        let file = Path::new("main.go");
2711        let errors = parse_go_vet_output(stderr, file);
2712        assert_eq!(errors.len(), 2);
2713        assert_eq!(errors[0].line, 10);
2714        assert_eq!(errors[0].column, 5);
2715        assert!(errors[0].message.contains("unreachable code"));
2716        assert_eq!(errors[1].line, 20);
2717        assert_eq!(errors[1].column, 0);
2718    }
2719
2720    #[test]
2721    fn parse_pyright_output_basic() {
2722        let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
2723        let file = Path::new("test.py");
2724        let errors = parse_pyright_output(stdout, file);
2725        assert_eq!(errors.len(), 1);
2726        assert_eq!(errors[0].line, 5); // 0-indexed → 1-indexed
2727        assert_eq!(errors[0].column, 11);
2728        assert_eq!(errors[0].severity, "error");
2729        assert!(errors[0].message.contains("Type error here"));
2730    }
2731
2732    #[test]
2733    fn validate_full_unsupported_language() {
2734        let dir = tempfile::tempdir().unwrap();
2735        let path = dir.path().join("file.txt");
2736        fs::write(&path, "hello").unwrap();
2737
2738        let config = Config::default();
2739        let (errors, reason) = validate_full(&path, &config);
2740        assert!(errors.is_empty());
2741        assert_eq!(reason.as_deref(), Some("unsupported_language"));
2742    }
2743
2744    #[test]
2745    fn detect_type_checker_rust() {
2746        let dir = tempfile::tempdir().unwrap();
2747        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2748        let path = dir.path().join("src/main.rs");
2749        let config = Config {
2750            project_root: Some(dir.path().to_path_buf()),
2751            ..Config::default()
2752        };
2753        let result = detect_type_checker(&path, LangId::Rust, &config);
2754        if resolve_tool("cargo", config.project_root.as_deref()).is_some() {
2755            let (cmd, args) = result.unwrap();
2756            assert_eq!(
2757                std::path::Path::new(&cmd)
2758                    .file_stem()
2759                    .and_then(|s| s.to_str())
2760                    .unwrap_or(""),
2761                "cargo",
2762                "expected cargo, got {cmd}"
2763            );
2764            assert!(args.contains(&"check".to_string()));
2765        } else {
2766            assert!(result.is_none());
2767        }
2768    }
2769
2770    #[test]
2771    fn detect_type_checker_go() {
2772        let dir = tempfile::tempdir().unwrap();
2773        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
2774        let path = dir.path().join("main.go");
2775        let config = Config {
2776            project_root: Some(dir.path().to_path_buf()),
2777            ..Config::default()
2778        };
2779        let result = detect_type_checker(&path, LangId::Go, &config);
2780        if resolve_tool("go", config.project_root.as_deref()).is_some() {
2781            let (cmd, _args) = result.unwrap();
2782            // Resolved paths may be absolute after PATH / well-known lookup.
2783            let name = checker_executable_name(&cmd);
2784            assert!(
2785                name == "go" || name == "staticcheck",
2786                "expected go or staticcheck, got {cmd}"
2787            );
2788        } else {
2789            assert!(result.is_none());
2790        }
2791    }
2792
2793    #[cfg(unix)]
2794    #[test]
2795    fn detect_type_checker_defaults_to_tsc_for_typescript() {
2796        let _guard = tool_cache_test_lock();
2797        clear_tool_cache();
2798        let dir = tempfile::tempdir().unwrap();
2799        fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2800        let bin_dir = dir.path().join("node_modules").join(".bin");
2801        fs::create_dir_all(&bin_dir).unwrap();
2802        use std::os::unix::fs::PermissionsExt;
2803        let fake_tsc = bin_dir.join("tsc");
2804        fs::write(&fake_tsc, "#!/bin/sh\nexit 0").unwrap();
2805        fs::set_permissions(&fake_tsc, fs::Permissions::from_mode(0o755)).unwrap();
2806        let fake_tsgo = bin_dir.join("tsgo");
2807        fs::write(&fake_tsgo, "#!/bin/sh\nexit 0").unwrap();
2808        fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2809
2810        let path = dir.path().join("src/app.ts");
2811        let config = Config {
2812            project_root: Some(dir.path().to_path_buf()),
2813            ..Config::default()
2814        };
2815
2816        let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
2817        assert!(cmd.ends_with("tsc"), "expected tsc by default, got: {cmd}");
2818        assert_eq!(args, vec!["--noEmit", "--pretty", "false"]);
2819    }
2820
2821    #[cfg(unix)]
2822    #[test]
2823    fn detect_type_checker_uses_tsgo_when_explicitly_configured() {
2824        let _guard = tool_cache_test_lock();
2825        clear_tool_cache();
2826        let dir = tempfile::tempdir().unwrap();
2827        fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2828        let bin_dir = dir.path().join("node_modules").join(".bin");
2829        fs::create_dir_all(&bin_dir).unwrap();
2830        use std::os::unix::fs::PermissionsExt;
2831        let fake_tsgo = bin_dir.join("tsgo");
2832        fs::write(&fake_tsgo, "#!/bin/sh\nexit 0").unwrap();
2833        fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2834
2835        let path = dir.path().join("src/app.ts");
2836        let mut config = Config {
2837            project_root: Some(dir.path().to_path_buf()),
2838            ..Config::default()
2839        };
2840        config
2841            .checker
2842            .insert("typescript".to_string(), "tsgo".to_string());
2843
2844        let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
2845        assert!(cmd.ends_with("tsgo"), "expected tsgo, got: {cmd}");
2846        assert_eq!(args, vec!["--noEmit", "--pretty", "false"]);
2847    }
2848
2849    #[cfg(unix)]
2850    #[test]
2851    fn validate_full_explicit_tsgo_parses_diagnostics() {
2852        let _guard = tool_cache_test_lock();
2853        clear_tool_cache();
2854        let dir = tempfile::tempdir().unwrap();
2855        fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2856        let src_dir = dir.path().join("src");
2857        fs::create_dir_all(&src_dir).unwrap();
2858        let path = src_dir.join("app.ts");
2859        fs::write(&path, "const value: number = 'nope';\n").unwrap();
2860
2861        let bin_dir = dir.path().join("node_modules").join(".bin");
2862        fs::create_dir_all(&bin_dir).unwrap();
2863        use std::os::unix::fs::PermissionsExt;
2864        let fake_tsgo = bin_dir.join("tsgo");
2865        fs::write(
2866            &fake_tsgo,
2867            "#!/bin/sh\nif [ \"$1 $2 $3\" != \"--noEmit --pretty false\" ]; then echo \"bad args: $*\" >&2; exit 3; fi\nprintf '%s\n' \"src/app.ts(1,23): error TS2322: Type 'string' is not assignable to type 'number'.\"\nexit 2\n",
2868        )
2869        .unwrap();
2870        fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2871
2872        let mut config = Config {
2873            project_root: Some(dir.path().to_path_buf()),
2874            ..Config::default()
2875        };
2876        config
2877            .checker
2878            .insert("typescript".to_string(), "tsgo".to_string());
2879
2880        let (errors, reason) = validate_full(&path, &config);
2881        assert_eq!(reason, None);
2882        assert_eq!(errors.len(), 1);
2883        assert_eq!(errors[0].line, 1);
2884        assert_eq!(errors[0].column, 23);
2885        assert!(errors[0].message.contains("TS2322"));
2886    }
2887
2888    #[test]
2889    fn run_external_tool_capture_nonzero_not_error() {
2890        // `false` exits with code 1 — capture should still return Ok
2891        let result = run_external_tool_capture("false", &[], None, 5);
2892        assert!(result.is_ok(), "capture should not error on non-zero exit");
2893        assert_eq!(result.unwrap().exit_code, 1);
2894    }
2895
2896    #[test]
2897    fn run_external_tool_capture_not_found() {
2898        let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
2899        assert!(result.is_err());
2900        match result.unwrap_err() {
2901            FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
2902            other => panic!("expected NotFound, got: {:?}", other),
2903        }
2904    }
2905
2906    // GitHub issue #47: GUI-launched editors miss /opt/homebrew/bin etc. from
2907    // PATH. `try_well_known_path_lookup` should find the tool at well-known
2908    // install locations even when PATH wouldn't.
2909    #[cfg(unix)]
2910    #[test]
2911    fn well_known_search_paths_include_homebrew_cargo_go_and_local() {
2912        let home = std::ffi::OsString::from("/Users/test-home");
2913        let paths = well_known_search_paths("toolx", Some(&home));
2914        let strs: Vec<String> = paths
2915            .iter()
2916            .map(|p| p.to_string_lossy().into_owned())
2917            .collect();
2918        // Order matters: Homebrew prefixes come first so an installed-via-brew
2919        // tool wins over a HOME-rooted shim.
2920        assert_eq!(strs[0], "/opt/homebrew/bin/toolx");
2921        assert_eq!(strs[1], "/usr/local/bin/toolx");
2922        assert_eq!(strs[2], "/usr/local/go/bin/toolx");
2923        assert_eq!(strs[3], "/usr/bin/toolx");
2924        assert_eq!(strs[4], "/snap/bin/toolx");
2925        assert_eq!(strs[5], "/Users/test-home/.cargo/bin/toolx");
2926        assert_eq!(strs[6], "/Users/test-home/go/bin/toolx");
2927        assert_eq!(strs[7], "/Users/test-home/.local/bin/toolx");
2928        assert_eq!(strs.len(), 8);
2929    }
2930
2931    #[cfg(unix)]
2932    #[test]
2933    fn well_known_search_paths_skips_home_when_unset() {
2934        let paths = well_known_search_paths("toolx", None);
2935        assert_eq!(paths.len(), 5);
2936        assert!(paths[0].ends_with("opt/homebrew/bin/toolx"));
2937        assert!(paths[1].ends_with("usr/local/bin/toolx"));
2938        assert!(paths[2].ends_with("usr/local/go/bin/toolx"));
2939        assert!(paths[3].ends_with("usr/bin/toolx"));
2940        assert!(paths[4].ends_with("snap/bin/toolx"));
2941    }
2942
2943    #[cfg(unix)]
2944    #[test]
2945    fn try_well_known_path_lookup_in_finds_executable_file() {
2946        use std::os::unix::fs::PermissionsExt;
2947        let dir = tempfile::tempdir().unwrap();
2948        let bin_dir = dir.path().join("bin");
2949        fs::create_dir_all(&bin_dir).unwrap();
2950        let tool_path = bin_dir.join("toolx");
2951        fs::write(&tool_path, "#!/bin/sh\necho test").unwrap();
2952        let mut perms = fs::metadata(&tool_path).unwrap().permissions();
2953        perms.set_mode(0o755);
2954        fs::set_permissions(&tool_path, perms).unwrap();
2955
2956        let candidates = vec![
2957            dir.path().join("missing/toolx"),
2958            tool_path.clone(),
2959            dir.path().join("alt/toolx"),
2960        ];
2961        let found = try_well_known_path_lookup_in(&candidates);
2962        assert_eq!(found, Some(tool_path));
2963    }
2964
2965    #[cfg(unix)]
2966    #[test]
2967    fn try_well_known_path_lookup_in_skips_non_executable_file() {
2968        let dir = tempfile::tempdir().unwrap();
2969        let bin_dir = dir.path().join("bin");
2970        fs::create_dir_all(&bin_dir).unwrap();
2971        // File exists but is not marked executable (default 0o644 on most umasks).
2972        let tool_path = bin_dir.join("toolx");
2973        fs::write(&tool_path, "not a real tool").unwrap();
2974
2975        let found = try_well_known_path_lookup_in(&std::slice::from_ref(&tool_path));
2976        assert!(found.is_none(), "non-executable file should be skipped");
2977    }
2978
2979    #[cfg(unix)]
2980    #[test]
2981    fn try_well_known_path_lookup_in_skips_directories_and_missing_paths() {
2982        let dir = tempfile::tempdir().unwrap();
2983        // A directory at the expected path should not count as a tool.
2984        let candidates = vec![dir.path().to_path_buf(), dir.path().join("does-not-exist")];
2985        assert!(try_well_known_path_lookup_in(&candidates).is_none());
2986    }
2987
2988    #[cfg(windows)]
2989    #[test]
2990    fn try_well_known_path_lookup_finds_npm_global_shim() {
2991        let dir = tempfile::tempdir().unwrap();
2992        let npm_bin = dir.path().join("npm");
2993        fs::create_dir_all(&npm_bin).unwrap();
2994        let shim = npm_bin.join("biome.cmd");
2995        fs::write(&shim, "@echo off\n").unwrap();
2996
2997        let saved_disable = std::env::var_os("AFT_DISABLE_WELL_KNOWN_LOOKUP");
2998        std::env::remove_var("AFT_DISABLE_WELL_KNOWN_LOOKUP");
2999        let saved_appdata = std::env::var_os("APPDATA");
3000        std::env::set_var("APPDATA", dir.path());
3001
3002        let found = try_well_known_path_lookup("biome");
3003
3004        if let Some(value) = saved_appdata {
3005            std::env::set_var("APPDATA", value);
3006        } else {
3007            std::env::remove_var("APPDATA");
3008        }
3009        if let Some(value) = saved_disable {
3010            std::env::set_var("AFT_DISABLE_WELL_KNOWN_LOOKUP", value);
3011        }
3012
3013        assert_eq!(found.as_deref(), Some(shim.as_path()));
3014    }
3015
3016    // GitHub issue #47: wording must not claim "but not installed" — the tool
3017    // may be installed but missing from AFT's PATH (GUI-launched editor).
3018    #[test]
3019    fn configured_tool_hint_does_not_claim_not_installed() {
3020        let hint = configured_tool_hint("biome", "biome.json");
3021        assert!(
3022            hint.contains("was not found on PATH or in common install locations"),
3023            "hint should explain the PATH miss: got {:?}",
3024            hint
3025        );
3026        assert!(
3027            !hint.contains("but not installed"),
3028            "hint must not claim the tool isn't installed: got {:?}",
3029            hint
3030        );
3031    }
3032
3033    #[test]
3034    fn install_hint_for_go_mentions_path() {
3035        // Verify the Go-specific hint nudges users toward checking PATH
3036        // (Homebrew install location is the most common GUI-launch PATH miss).
3037        let hint = install_hint("go");
3038        assert!(
3039            hint.contains("PATH"),
3040            "go install hint should mention PATH: got {:?}",
3041            hint
3042        );
3043    }
3044
3045    #[test]
3046    fn read_bounded_to_string_truncates_after_limit() {
3047        let (text, truncated) = read_bounded_to_string(std::io::Cursor::new(b"abcdef"), 4);
3048        assert_eq!(text, "abcd");
3049        assert!(truncated);
3050
3051        let (text, truncated) = read_bounded_to_string(std::io::Cursor::new(b"abc"), 4);
3052        assert_eq!(text, "abc");
3053        assert!(!truncated);
3054    }
3055
3056    #[test]
3057    fn windows_local_node_bin_extensions_follow_pathext_then_defaults() {
3058        let pathext = std::ffi::OsString::from(".EXE;.CMD;.BAT;.CMD");
3059        let extensions = windows_local_node_bin_extensions(Some(&pathext));
3060        assert_eq!(extensions, vec![".exe", ".cmd", ".bat", ".ps1"]);
3061    }
3062
3063    #[test]
3064    fn checker_executable_name_strips_paths_and_windows_extensions() {
3065        assert_eq!(checker_executable_name("/usr/local/bin/ruff"), "ruff");
3066        assert_eq!(checker_executable_name(r"C:\Go\bin\go.exe"), "go");
3067        assert_eq!(
3068            checker_executable_name(r"C:\repo\node_modules\.bin\biome.cmd"),
3069            "biome"
3070        );
3071    }
3072
3073    #[test]
3074    fn parse_biome_output_json_reporter() {
3075        let dir = tempfile::tempdir().unwrap();
3076        let file = dir.path().join("src/app.ts");
3077        fs::create_dir_all(file.parent().unwrap()).unwrap();
3078        fs::write(&file, "const value = 1;\nconsole.log(value);\n").unwrap();
3079        // Build the JSON via serde so the path is correctly escaped on Windows
3080        // (backslashes in paths would otherwise break a raw JSON string literal).
3081        let stdout = serde_json::json!({
3082            "diagnostics": [
3083                {
3084                    "severity": "warning",
3085                    "description": "Avoid console.log",
3086                    "location": {
3087                        "path": { "file": file.to_string_lossy() },
3088                        "span": [17, 28],
3089                    },
3090                },
3091            ],
3092        })
3093        .to_string();
3094
3095        let errors = parse_biome_output(&stdout, "", &file);
3096        assert_eq!(errors.len(), 1);
3097        assert_eq!(errors[0].line, 2);
3098        assert_eq!(errors[0].column, 1);
3099        assert_eq!(errors[0].severity, "warning");
3100        assert!(errors[0].message.contains("Avoid console.log"));
3101    }
3102
3103    #[test]
3104    fn parse_ruff_output_json() {
3105        let stdout = r#"[{"filename":"pkg/main.py","location":{"row":3,"column":5},"code":"F401","message":"`os` imported but unused"}]"#;
3106        let errors = parse_ruff_output(stdout, "", Path::new("pkg/main.py"));
3107        assert_eq!(errors.len(), 1);
3108        assert_eq!(errors[0].line, 3);
3109        assert_eq!(errors[0].column, 5);
3110        assert!(errors[0].message.contains("F401"));
3111    }
3112
3113    #[test]
3114    fn parse_staticcheck_output_json_lines() {
3115        let stdout = r#"{"code":"SA4006","severity":"error","location":{"file":"C:\\repo\\main.go","line":10,"column":5},"message":"value is never used"}"#;
3116        let errors = parse_staticcheck_output(stdout, "", Path::new(r"C:\repo\main.go"));
3117        assert_eq!(errors.len(), 1);
3118        assert_eq!(errors[0].line, 10);
3119        assert_eq!(errors[0].column, 5);
3120        assert!(errors[0].message.contains("SA4006"));
3121    }
3122
3123    #[test]
3124    fn parse_go_vet_output_handles_windows_drive_letters() {
3125        let stderr = r"C:\repo\main.go:10:5: unreachable code
3126C:\repo\other.go:1:1: other file
3127";
3128        let errors = parse_go_vet_output(stderr, Path::new(r"C:\repo\main.go"));
3129        assert_eq!(errors.len(), 1);
3130        assert_eq!(errors[0].line, 10);
3131        assert_eq!(errors[0].column, 5);
3132        assert_eq!(errors[0].message, "unreachable code");
3133    }
3134
3135    #[cfg(unix)]
3136    #[test]
3137    fn detect_type_checker_biome_uses_json_reporter() {
3138        let _guard = tool_cache_test_lock();
3139        clear_tool_cache();
3140        let dir = tempfile::tempdir().unwrap();
3141        fs::write(dir.path().join("biome.json"), "{}\n").unwrap();
3142        let bin_dir = dir.path().join("node_modules").join(".bin");
3143        fs::create_dir_all(&bin_dir).unwrap();
3144        let fake = bin_dir.join("biome");
3145        fs::write(&fake, "#!/bin/sh\necho 1.0.0\n").unwrap();
3146        use std::os::unix::fs::PermissionsExt;
3147        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3148
3149        let path = dir.path().join("src/app.ts");
3150        let config = Config {
3151            project_root: Some(dir.path().to_path_buf()),
3152            ..Config::default()
3153        };
3154
3155        let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
3156        assert!(cmd.ends_with("biome"), "expected biome, got: {cmd}");
3157        assert_eq!(args[0], "check");
3158        assert!(args.contains(&"--reporter=json".to_string()));
3159    }
3160
3161    #[cfg(unix)]
3162    #[test]
3163    fn detect_type_checker_ruff_does_not_require_formatter_version() {
3164        let _guard = tool_cache_test_lock();
3165        clear_tool_cache();
3166        let dir = tempfile::tempdir().unwrap();
3167        fs::write(dir.path().join("ruff.toml"), "\n").unwrap();
3168        let bin_dir = dir.path().join("node_modules").join(".bin");
3169        fs::create_dir_all(&bin_dir).unwrap();
3170        let fake = bin_dir.join("ruff");
3171        fs::write(&fake, "#!/bin/sh\necho 'ruff 0.0.1'\n").unwrap();
3172        use std::os::unix::fs::PermissionsExt;
3173        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3174
3175        let path = dir.path().join("main.py");
3176        let config = Config {
3177            project_root: Some(dir.path().to_path_buf()),
3178            ..Config::default()
3179        };
3180
3181        assert!(!ruff_format_available(config.project_root.as_deref()));
3182        let (cmd, args) = detect_type_checker(&path, LangId::Python, &config).unwrap();
3183        assert!(cmd.ends_with("ruff"), "expected ruff checker, got: {cmd}");
3184        assert_eq!(args[0], "check");
3185        assert!(args.contains(&"--output-format=json".to_string()));
3186    }
3187
3188    #[cfg(unix)]
3189    #[test]
3190    fn detect_type_checker_staticcheck_uses_json_reporter() {
3191        let _guard = tool_cache_test_lock();
3192        clear_tool_cache();
3193        let dir = tempfile::tempdir().unwrap();
3194        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21\n").unwrap();
3195        let bin_dir = dir.path().join("node_modules").join(".bin");
3196        fs::create_dir_all(&bin_dir).unwrap();
3197        let fake = bin_dir.join("staticcheck");
3198        fs::write(&fake, "#!/bin/sh\necho staticcheck\n").unwrap();
3199        use std::os::unix::fs::PermissionsExt;
3200        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3201
3202        let path = dir.path().join("main.go");
3203        let config = Config {
3204            project_root: Some(dir.path().to_path_buf()),
3205            ..Config::default()
3206        };
3207
3208        let (cmd, args) = detect_type_checker(&path, LangId::Go, &config).unwrap();
3209        assert!(
3210            cmd.ends_with("staticcheck"),
3211            "expected staticcheck, got: {cmd}"
3212        );
3213        assert_eq!(args[0], "-f");
3214        assert_eq!(args[1], "json");
3215    }
3216
3217    #[cfg(unix)]
3218    #[test]
3219    fn detect_type_checker_uses_resolved_cargo_and_go_paths() {
3220        let _guard = tool_cache_test_lock();
3221        clear_tool_cache();
3222        let dir = tempfile::tempdir().unwrap();
3223        let bin_dir = dir.path().join("node_modules").join(".bin");
3224        fs::create_dir_all(&bin_dir).unwrap();
3225        use std::os::unix::fs::PermissionsExt;
3226        for name in ["cargo", "go"] {
3227            let fake = bin_dir.join(name);
3228            fs::write(&fake, "#!/bin/sh\necho fake\n").unwrap();
3229            fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3230        }
3231
3232        fs::write(
3233            dir.path().join("Cargo.toml"),
3234            "[package]\nname = \"test\"\n",
3235        )
3236        .unwrap();
3237        let rust_config = Config {
3238            project_root: Some(dir.path().to_path_buf()),
3239            ..Config::default()
3240        };
3241        let (cargo_cmd, _) =
3242            detect_type_checker(&dir.path().join("src/main.rs"), LangId::Rust, &rust_config)
3243                .unwrap();
3244        assert_eq!(cargo_cmd, bin_dir.join("cargo").to_string_lossy());
3245
3246        fs::remove_file(dir.path().join("Cargo.toml")).unwrap();
3247        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21\n").unwrap();
3248        let mut go_config = Config {
3249            project_root: Some(dir.path().to_path_buf()),
3250            ..Config::default()
3251        };
3252        go_config.checker.insert("go".to_string(), "go".to_string());
3253        let (go_cmd, _) =
3254            detect_type_checker(&dir.path().join("main.go"), LangId::Go, &go_config).unwrap();
3255        assert_eq!(go_cmd, bin_dir.join("go").to_string_lossy());
3256    }
3257}