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