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