Skip to main content

aft/
format.rs

1//! External tool runner and auto-formatter detection.
2//!
3//! Provides subprocess execution with timeout protection, language-to-formatter
4//! mapping, and the `auto_format` entry point used by `write_format_validate`.
5
6use std::collections::{HashMap, HashSet};
7use std::io::{ErrorKind, Read};
8use std::path::{Path, PathBuf};
9use std::process::{Child, Command, ExitStatus, Stdio};
10use std::sync::Mutex;
11use std::thread;
12use std::time::{Duration, Instant};
13
14use crate::config::Config;
15use crate::parser::{detect_language, LangId};
16
17/// Result of running an external tool subprocess.
18#[derive(Debug)]
19pub struct ExternalToolResult {
20    pub stdout: String,
21    pub stderr: String,
22    pub exit_code: i32,
23}
24
25struct SubprocessOutcome {
26    stdout: String,
27    stderr: String,
28    status: ExitStatus,
29}
30
31/// Errors from external tool execution.
32#[derive(Debug)]
33pub enum FormatError {
34    /// The tool binary was not found on PATH.
35    NotFound { tool: String },
36    /// The tool exceeded its timeout and was killed.
37    Timeout { tool: String, timeout_secs: u32 },
38    /// The tool exited with a non-zero status.
39    Failed { tool: String, stderr: String },
40    /// No formatter is configured for this language.
41    UnsupportedLanguage,
42}
43
44/// A configured formatter/checker that cannot be resolved for configure warnings.
45#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
46pub struct MissingTool {
47    pub kind: String,
48    pub language: String,
49    pub tool: String,
50    pub hint: String,
51}
52
53#[derive(Debug, Clone)]
54struct ToolCandidate {
55    tool: String,
56    source: String,
57    args: Vec<String>,
58    required: bool,
59}
60
61#[derive(Debug, Clone)]
62enum ToolDetection {
63    Found(String, Vec<String>),
64    NotConfigured,
65    NotInstalled { tool: String },
66}
67
68impl std::fmt::Display for FormatError {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            FormatError::NotFound { tool } => write!(f, "formatter not found: {}", tool),
72            FormatError::Timeout { tool, timeout_secs } => {
73                write!(f, "formatter '{}' timed out after {}s", tool, timeout_secs)
74            }
75            FormatError::Failed { tool, stderr } => {
76                write!(f, "formatter '{}' failed: {}", tool, stderr)
77            }
78            FormatError::UnsupportedLanguage => write!(f, "unsupported language for formatting"),
79        }
80    }
81}
82
83/// Apply Unix-specific isolation so a kill() on timeout terminates
84/// grandchildren too (e.g. `sh -c 'sleep 60'` orphaning `sleep`).
85///
86/// Without this, killing the immediate child (`sh`) leaves `sleep`
87/// holding stdout/stderr pipes open, and the reader threads block
88/// until `sleep` terminates — turning a 2s timeout into a 60s hang.
89#[cfg(unix)]
90fn isolate_in_process_group(cmd: &mut Command) {
91    use std::os::unix::process::CommandExt;
92    // SAFETY: setsid is async-signal-safe.
93    unsafe {
94        cmd.pre_exec(|| {
95            if libc::setsid() == -1 {
96                return Err(std::io::Error::last_os_error());
97            }
98            Ok(())
99        });
100    }
101}
102
103#[cfg(not(unix))]
104fn isolate_in_process_group(_cmd: &mut Command) {
105    // Best-effort no-op on Windows; child.kill() does not propagate
106    // to grandchildren but reader threads will still close pipes
107    // when the immediate child exits.
108}
109
110/// Kill the child and (on Unix) its entire process group, so orphaned
111/// grandchildren don't keep pipes open after a timeout.
112#[cfg(unix)]
113fn kill_process_tree(child: &mut Child) {
114    let pid = child.id() as i32;
115    if pid > 0 {
116        // SAFETY: killpg with SIGKILL on a process group leader is safe.
117        // Negative pid form (kill -pgid) targets the whole group.
118        unsafe {
119            libc::killpg(pid, libc::SIGKILL);
120        }
121    }
122    let _ = child.kill();
123}
124
125#[cfg(not(unix))]
126fn kill_process_tree(child: &mut Child) {
127    let _ = child.kill();
128}
129
130/// Spawn a subprocess and wait for completion with timeout protection.
131///
132/// Polls `try_wait()` at 50ms intervals. On timeout, kills the child process
133/// and waits for it to exit. Returns `FormatError::NotFound` when the binary
134/// isn't on PATH.
135pub fn run_external_tool(
136    command: &str,
137    args: &[&str],
138    working_dir: Option<&Path>,
139    timeout_secs: u32,
140) -> Result<ExternalToolResult, FormatError> {
141    let mut cmd = Command::new(command);
142    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
143
144    if let Some(dir) = working_dir {
145        cmd.current_dir(dir);
146    }
147
148    isolate_in_process_group(&mut cmd);
149
150    let child = match cmd.spawn() {
151        Ok(c) => c,
152        Err(e) if e.kind() == ErrorKind::NotFound => {
153            return Err(FormatError::NotFound {
154                tool: command.to_string(),
155            });
156        }
157        Err(e) => {
158            return Err(FormatError::Failed {
159                tool: command.to_string(),
160                stderr: e.to_string(),
161            });
162        }
163    };
164
165    let outcome = wait_with_timeout(child, command, timeout_secs)?;
166    let exit_code = outcome.status.code().unwrap_or(-1);
167    if exit_code != 0 {
168        return Err(FormatError::Failed {
169            tool: command.to_string(),
170            stderr: outcome.stderr,
171        });
172    }
173
174    Ok(ExternalToolResult {
175        stdout: outcome.stdout,
176        stderr: outcome.stderr,
177        exit_code,
178    })
179}
180
181fn wait_with_timeout(
182    mut child: Child,
183    command: &str,
184    timeout_secs: u32,
185) -> Result<SubprocessOutcome, FormatError> {
186    let mut stdout_pipe = child.stdout.take().expect("piped stdout");
187    let mut stderr_pipe = child.stderr.take().expect("piped stderr");
188    let stdout_thread = thread::spawn(move || {
189        let mut buf = String::new();
190        let _ = stdout_pipe.read_to_string(&mut buf);
191        buf
192    });
193    let stderr_thread = thread::spawn(move || {
194        let mut buf = String::new();
195        let _ = stderr_pipe.read_to_string(&mut buf);
196        buf
197    });
198    let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
199
200    loop {
201        match child.try_wait() {
202            Ok(Some(status)) => {
203                let stdout = stdout_thread.join().unwrap_or_default();
204                let stderr = stderr_thread.join().unwrap_or_default();
205                return Ok(SubprocessOutcome {
206                    stdout,
207                    stderr,
208                    status,
209                });
210            }
211            Ok(None) => {
212                if Instant::now() >= deadline {
213                    kill_process_tree(&mut child);
214                    let _ = child.wait();
215                    // Do NOT block joining the reader threads — orphaned
216                    // grandchildren may still hold the pipes open even after
217                    // the immediate child is gone. The threads will detach
218                    // and clean up when pipes finally close.
219                    return Err(FormatError::Timeout {
220                        tool: command.to_string(),
221                        timeout_secs,
222                    });
223                }
224                thread::sleep(Duration::from_millis(50));
225            }
226            Err(e) => {
227                kill_process_tree(&mut child);
228                let _ = child.wait();
229                // Same rationale as the timeout branch: don't block on join.
230                return Err(FormatError::Failed {
231                    tool: command.to_string(),
232                    stderr: format!("try_wait error: {}", e),
233                });
234            }
235        }
236    }
237}
238
239/// TTL for tool availability and resolution cache entries.
240const TOOL_CACHE_TTL: Duration = Duration::from_secs(60);
241
242#[derive(Debug, Clone, PartialEq, Eq, Hash)]
243struct ToolCacheKey {
244    command: String,
245    project_root: PathBuf,
246}
247
248static TOOL_RESOLUTION_CACHE: std::sync::LazyLock<
249    Mutex<HashMap<ToolCacheKey, (Option<PathBuf>, Instant)>>,
250> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
251
252static TOOL_AVAILABILITY_CACHE: std::sync::LazyLock<Mutex<HashMap<String, (bool, Instant)>>> =
253    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
254
255fn tool_cache_key(command: &str, project_root: Option<&Path>) -> ToolCacheKey {
256    ToolCacheKey {
257        command: command.to_string(),
258        project_root: project_root.map(Path::to_path_buf).unwrap_or_default(),
259    }
260}
261
262fn availability_cache_key(command: &str, project_root: Option<&Path>) -> String {
263    let root = project_root
264        .map(|path| path.to_string_lossy())
265        .unwrap_or_default();
266    format!("{}\0{}", command, root)
267}
268
269pub fn clear_tool_cache() {
270    if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
271        cache.clear();
272    }
273    if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
274        cache.clear();
275    }
276}
277
278/// Resolve a tool by checking node_modules/.bin relative to project_root, then PATH.
279/// Returns the full path to the tool if found, otherwise None.
280fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
281    let key = tool_cache_key(command, project_root);
282    if let Ok(cache) = TOOL_RESOLUTION_CACHE.lock() {
283        if let Some((resolved, checked_at)) = cache.get(&key) {
284            if checked_at.elapsed() < TOOL_CACHE_TTL {
285                return resolved
286                    .as_ref()
287                    .map(|path| path.to_string_lossy().to_string());
288            }
289        }
290    }
291
292    let resolved = resolve_tool_uncached(command, project_root);
293    if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
294        cache.insert(key, (resolved.clone(), Instant::now()));
295    }
296    resolved.map(|path| path.to_string_lossy().to_string())
297}
298
299fn resolve_tool_uncached(command: &str, project_root: Option<&Path>) -> Option<PathBuf> {
300    // 1. Check node_modules/.bin/<command> relative to project root
301    if let Some(root) = project_root {
302        let local_bin = root.join("node_modules").join(".bin").join(command);
303        if local_bin.exists() {
304            return Some(local_bin);
305        }
306    }
307
308    // 2. Try PATH lookup first. This is the fast common path: spawning the
309    // tool with `--version` and waiting briefly for it to exit. When the
310    // editor (OpenCode, Pi, etc.) is launched from a login shell the PATH
311    // is usually complete, so this finds Homebrew/cargo/etc. binaries.
312    if let Some(path) = try_path_lookup(command) {
313        return Some(path);
314    }
315
316    // 3. Fall back to well-known install locations the editor's PATH may
317    // not contain. GitHub issue #47: macOS GUI launches (Spotlight, Dock,
318    // Alfred) and some Linux desktop launchers drop /opt/homebrew/bin and
319    // similar from PATH, making PATH lookups fail even though the user
320    // genuinely has the tool installed. Returning the absolute path here
321    // means downstream `Command::new(resolved)` works regardless.
322    try_well_known_path_lookup(command)
323}
324
325/// Try spawning the tool via the inherited PATH. Returns the bare command
326/// name on success (downstream `Command::new` re-resolves through PATH),
327/// or None if the spawn fails or the tool exits with non-zero status.
328fn try_path_lookup(command: &str) -> Option<PathBuf> {
329    let mut child = Command::new(command)
330        .arg("--version")
331        .stdin(Stdio::null())
332        .stdout(Stdio::null())
333        .stderr(Stdio::null())
334        .spawn()
335        .ok()?;
336    let start = Instant::now();
337    let timeout = Duration::from_secs(2);
338    loop {
339        match child.try_wait() {
340            Ok(Some(status)) => {
341                return if status.success() {
342                    Some(PathBuf::from(command))
343                } else {
344                    None
345                };
346            }
347            Ok(None) if start.elapsed() > timeout => {
348                let _ = child.kill();
349                let _ = child.wait();
350                return None;
351            }
352            Ok(None) => thread::sleep(Duration::from_millis(50)),
353            Err(_) => return None,
354        }
355    }
356}
357
358/// Look up `command` in the well-known install locations that GUI-launched
359/// editors commonly miss from PATH. Returns the absolute path so the caller
360/// invokes the tool via `Command::new(absolute_path)` regardless of PATH.
361///
362/// Search order is built by `well_known_search_paths`:
363/// 1. `/opt/homebrew/bin` (Apple Silicon Homebrew)
364/// 2. `/usr/local/bin` (Intel Mac Homebrew + most manual Linux installs)
365/// 3. `$HOME/.cargo/bin` (cargo install — rustfmt, etc.)
366/// 4. `$HOME/go/bin` (`go install` default GOPATH layout)
367/// 5. `$HOME/.local/bin` (pip --user, pipx, npm prefix, many shell scripts)
368///
369/// Each candidate is verified to (a) exist as a regular file and (b) be
370/// executable; we don't spawn `--version` here because spawning an
371/// absolute-path candidate that doesn't accept `--version` would emit a
372/// false negative (and Rust's `fs::metadata` is much cheaper than a spawn).
373fn try_well_known_path_lookup(command: &str) -> Option<PathBuf> {
374    if cfg!(windows) {
375        // On Windows, well-known POSIX paths don't apply. Skip the fallback
376        // entirely — the user's tool is either on PATH or genuinely missing.
377        return None;
378    }
379    // Test-only escape hatch: integration tests that need to assert
380    // "tool not installed" semantics set AFT_DISABLE_WELL_KNOWN_LOOKUP=1
381    // so CI runners with a system tsc/biome/etc. at /usr/local/bin don't
382    // silently make those tests pass. Production callers never set this.
383    if std::env::var_os("AFT_DISABLE_WELL_KNOWN_LOOKUP").is_some() {
384        return None;
385    }
386    let candidates = well_known_search_paths(command, std::env::var_os("HOME").as_deref());
387    try_well_known_path_lookup_in(&candidates)
388}
389
390/// Build the candidate path list for the given command name and HOME value.
391/// Extracted so tests can drive the lookup with a controlled HOME without
392/// mutating process-global env vars.
393fn well_known_search_paths(command: &str, home: Option<&std::ffi::OsStr>) -> Vec<PathBuf> {
394    let mut candidates: Vec<PathBuf> = Vec::with_capacity(5);
395    candidates.push(PathBuf::from("/opt/homebrew/bin").join(command));
396    candidates.push(PathBuf::from("/usr/local/bin").join(command));
397    if let Some(home) = home {
398        let home_path = PathBuf::from(home);
399        candidates.push(home_path.join(".cargo/bin").join(command));
400        candidates.push(home_path.join("go/bin").join(command));
401        candidates.push(home_path.join(".local/bin").join(command));
402    }
403    candidates
404}
405
406/// Walk a pre-built candidate list, returning the first file that exists and
407/// is executable. Extracted from `try_well_known_path_lookup` so tests can
408/// inject candidates anchored at a tempdir.
409fn try_well_known_path_lookup_in(candidates: &[PathBuf]) -> Option<PathBuf> {
410    for candidate in candidates {
411        if let Ok(metadata) = std::fs::metadata(candidate) {
412            if metadata.is_file() && is_executable(&metadata) {
413                return Some(candidate.clone());
414            }
415        }
416    }
417    None
418}
419
420#[cfg(unix)]
421fn is_executable(metadata: &std::fs::Metadata) -> bool {
422    use std::os::unix::fs::PermissionsExt;
423    metadata.permissions().mode() & 0o111 != 0
424}
425
426#[cfg(not(unix))]
427fn is_executable(_metadata: &std::fs::Metadata) -> bool {
428    // Windows: regular files in well-known POSIX paths don't apply
429    // (try_well_known_path_lookup returns early on Windows). This stub
430    // exists only so the file compiles on Windows.
431    true
432}
433
434/// Check if `ruff format` is available with a stable formatter.
435///
436/// Ruff's formatter became stable in v0.1.2. Versions before that output
437/// `NOT_YET_IMPLEMENTED_*` stubs instead of formatted code. We parse the
438/// version from `ruff --version` (format: "ruff X.Y.Z") and require >= 0.1.2.
439/// Falls back to false if ruff is not found or version cannot be parsed.
440fn ruff_format_available(project_root: Option<&Path>) -> bool {
441    let key = availability_cache_key("ruff-format", project_root);
442    if let Ok(cache) = TOOL_AVAILABILITY_CACHE.lock() {
443        if let Some((available, checked_at)) = cache.get(&key) {
444            if checked_at.elapsed() < TOOL_CACHE_TTL {
445                return *available;
446            }
447        }
448    }
449
450    let result = ruff_format_available_uncached(project_root);
451    if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
452        cache.insert(key, (result, Instant::now()));
453    }
454    result
455}
456
457fn ruff_format_available_uncached(project_root: Option<&Path>) -> bool {
458    let command = match resolve_tool("ruff", project_root) {
459        Some(command) => command,
460        None => return false,
461    };
462    let output = match Command::new(&command)
463        .arg("--version")
464        .stdout(Stdio::piped())
465        .stderr(Stdio::null())
466        .output()
467    {
468        Ok(o) => o,
469        Err(_) => return false,
470    };
471
472    let version_str = String::from_utf8_lossy(&output.stdout);
473    // Parse "ruff X.Y.Z" or just "X.Y.Z"
474    let version_part = version_str
475        .trim()
476        .strip_prefix("ruff ")
477        .unwrap_or(version_str.trim());
478
479    let parts: Vec<&str> = version_part.split('.').collect();
480    if parts.len() < 3 {
481        return false;
482    }
483
484    let major: u32 = match parts[0].parse() {
485        Ok(v) => v,
486        Err(_) => return false,
487    };
488    let minor: u32 = match parts[1].parse() {
489        Ok(v) => v,
490        Err(_) => return false,
491    };
492    let patch: u32 = match parts[2].parse() {
493        Ok(v) => v,
494        Err(_) => return false,
495    };
496
497    // Require >= 0.1.2 where ruff format became stable
498    (major, minor, patch) >= (0, 1, 2)
499}
500
501fn resolve_candidate_tool(
502    candidate: &ToolCandidate,
503    project_root: Option<&Path>,
504) -> Option<String> {
505    if candidate.tool == "ruff" && !ruff_format_available(project_root) {
506        return None;
507    }
508
509    resolve_tool(&candidate.tool, project_root)
510}
511
512fn lang_key(lang: LangId) -> &'static str {
513    match lang {
514        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
515        LangId::Python => "python",
516        LangId::Rust => "rust",
517        LangId::Go => "go",
518        LangId::C => "c",
519        LangId::Cpp => "cpp",
520        LangId::Zig => "zig",
521        LangId::CSharp => "csharp",
522        LangId::Bash => "bash",
523        LangId::Solidity => "solidity",
524        LangId::Vue => "vue",
525        LangId::Json => "json",
526        LangId::Scala => "scala",
527        LangId::Java => "java",
528        LangId::Ruby => "ruby",
529        LangId::Kotlin => "kotlin",
530        LangId::Swift => "swift",
531        LangId::Php => "php",
532        LangId::Lua => "lua",
533        LangId::Perl => "perl",
534        LangId::Html => "html",
535        LangId::Markdown => "markdown",
536    }
537}
538
539fn has_formatter_support(lang: LangId) -> bool {
540    matches!(
541        lang,
542        LangId::TypeScript
543            | LangId::JavaScript
544            | LangId::Tsx
545            | LangId::Python
546            | LangId::Rust
547            | LangId::Go
548    )
549}
550
551fn has_checker_support(lang: LangId) -> bool {
552    matches!(
553        lang,
554        LangId::TypeScript
555            | LangId::JavaScript
556            | LangId::Tsx
557            | LangId::Python
558            | LangId::Rust
559            | LangId::Go
560    )
561}
562
563fn formatter_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
564    let project_root = config.project_root.as_deref();
565    if let Some(preferred) = config.formatter.get(lang_key(lang)) {
566        return explicit_formatter_candidate(preferred, file_str);
567    }
568
569    match lang {
570        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
571            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
572                vec![ToolCandidate {
573                    tool: "biome".to_string(),
574                    source: "biome.json".to_string(),
575                    args: vec![
576                        "format".to_string(),
577                        "--write".to_string(),
578                        file_str.to_string(),
579                    ],
580                    required: true,
581                }]
582            } else if has_project_config(
583                project_root,
584                &[
585                    ".prettierrc",
586                    ".prettierrc.json",
587                    ".prettierrc.yml",
588                    ".prettierrc.yaml",
589                    ".prettierrc.js",
590                    ".prettierrc.cjs",
591                    ".prettierrc.mjs",
592                    ".prettierrc.toml",
593                    "prettier.config.js",
594                    "prettier.config.cjs",
595                    "prettier.config.mjs",
596                ],
597            ) {
598                vec![ToolCandidate {
599                    tool: "prettier".to_string(),
600                    source: "Prettier config".to_string(),
601                    args: vec!["--write".to_string(), file_str.to_string()],
602                    required: true,
603                }]
604            } else if has_project_config(project_root, &["deno.json", "deno.jsonc"]) {
605                vec![ToolCandidate {
606                    tool: "deno".to_string(),
607                    source: "deno.json".to_string(),
608                    args: vec!["fmt".to_string(), file_str.to_string()],
609                    required: true,
610                }]
611            } else {
612                Vec::new()
613            }
614        }
615        LangId::Python => {
616            if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
617                || has_pyproject_tool(project_root, "ruff")
618            {
619                vec![ToolCandidate {
620                    tool: "ruff".to_string(),
621                    source: "ruff config".to_string(),
622                    args: vec!["format".to_string(), file_str.to_string()],
623                    required: true,
624                }]
625            } else if has_pyproject_tool(project_root, "black") {
626                vec![ToolCandidate {
627                    tool: "black".to_string(),
628                    source: "pyproject.toml".to_string(),
629                    args: vec![file_str.to_string()],
630                    required: true,
631                }]
632            } else {
633                Vec::new()
634            }
635        }
636        LangId::Rust => {
637            if has_project_config(project_root, &["Cargo.toml"]) {
638                vec![ToolCandidate {
639                    tool: "rustfmt".to_string(),
640                    source: "Cargo.toml".to_string(),
641                    args: vec![file_str.to_string()],
642                    required: true,
643                }]
644            } else {
645                Vec::new()
646            }
647        }
648        LangId::Go => {
649            if has_project_config(project_root, &["go.mod"]) {
650                vec![
651                    ToolCandidate {
652                        tool: "goimports".to_string(),
653                        source: "go.mod".to_string(),
654                        args: vec!["-w".to_string(), file_str.to_string()],
655                        required: false,
656                    },
657                    ToolCandidate {
658                        tool: "gofmt".to_string(),
659                        source: "go.mod".to_string(),
660                        args: vec!["-w".to_string(), file_str.to_string()],
661                        required: true,
662                    },
663                ]
664            } else {
665                Vec::new()
666            }
667        }
668        LangId::C
669        | LangId::Cpp
670        | LangId::Zig
671        | LangId::CSharp
672        | LangId::Bash
673        | LangId::Solidity
674        | LangId::Vue
675        | LangId::Json
676        | LangId::Scala
677        | LangId::Java
678        | LangId::Ruby
679        | LangId::Kotlin
680        | LangId::Swift
681        | LangId::Php
682        | LangId::Lua
683        | LangId::Perl => Vec::new(),
684        LangId::Html => Vec::new(),
685        LangId::Markdown => Vec::new(),
686    }
687}
688
689fn checker_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
690    let project_root = config.project_root.as_deref();
691    if let Some(preferred) = config.checker.get(lang_key(lang)) {
692        return explicit_checker_candidate(preferred, file_str);
693    }
694
695    match lang {
696        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
697            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
698                vec![ToolCandidate {
699                    tool: "biome".to_string(),
700                    source: "biome.json".to_string(),
701                    args: vec!["check".to_string(), file_str.to_string()],
702                    required: true,
703                }]
704            } else if has_project_config(project_root, &["tsconfig.json"]) {
705                vec![ToolCandidate {
706                    tool: "tsc".to_string(),
707                    source: "tsconfig.json".to_string(),
708                    args: vec![
709                        "--noEmit".to_string(),
710                        "--pretty".to_string(),
711                        "false".to_string(),
712                    ],
713                    required: true,
714                }]
715            } else {
716                Vec::new()
717            }
718        }
719        LangId::Python => {
720            if has_project_config(project_root, &["pyrightconfig.json"])
721                || has_pyproject_tool(project_root, "pyright")
722            {
723                vec![ToolCandidate {
724                    tool: "pyright".to_string(),
725                    source: "pyright config".to_string(),
726                    args: vec!["--outputjson".to_string(), file_str.to_string()],
727                    required: true,
728                }]
729            } else if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
730                || has_pyproject_tool(project_root, "ruff")
731            {
732                vec![ToolCandidate {
733                    tool: "ruff".to_string(),
734                    source: "ruff config".to_string(),
735                    args: vec![
736                        "check".to_string(),
737                        "--output-format=json".to_string(),
738                        file_str.to_string(),
739                    ],
740                    required: true,
741                }]
742            } else {
743                Vec::new()
744            }
745        }
746        LangId::Rust => {
747            if has_project_config(project_root, &["Cargo.toml"]) {
748                vec![ToolCandidate {
749                    tool: "cargo".to_string(),
750                    source: "Cargo.toml".to_string(),
751                    args: vec!["check".to_string(), "--message-format=json".to_string()],
752                    required: true,
753                }]
754            } else {
755                Vec::new()
756            }
757        }
758        LangId::Go => {
759            if has_project_config(project_root, &["go.mod"]) {
760                vec![
761                    ToolCandidate {
762                        tool: "staticcheck".to_string(),
763                        source: "go.mod".to_string(),
764                        args: vec![file_str.to_string()],
765                        required: false,
766                    },
767                    ToolCandidate {
768                        tool: "go".to_string(),
769                        source: "go.mod".to_string(),
770                        args: vec!["vet".to_string(), file_str.to_string()],
771                        required: true,
772                    },
773                ]
774            } else {
775                Vec::new()
776            }
777        }
778        LangId::C
779        | LangId::Cpp
780        | LangId::Zig
781        | LangId::CSharp
782        | LangId::Bash
783        | LangId::Solidity
784        | LangId::Vue
785        | LangId::Json
786        | LangId::Scala
787        | LangId::Java
788        | LangId::Ruby
789        | LangId::Kotlin
790        | LangId::Swift
791        | LangId::Php
792        | LangId::Lua
793        | LangId::Perl => Vec::new(),
794        LangId::Html => Vec::new(),
795        LangId::Markdown => Vec::new(),
796    }
797}
798
799fn explicit_formatter_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
800    match name {
801        "none" | "off" | "false" => Vec::new(),
802        "biome" => vec![ToolCandidate {
803            tool: name.to_string(),
804            source: "formatter config".to_string(),
805            args: vec![
806                "format".to_string(),
807                "--write".to_string(),
808                file_str.to_string(),
809            ],
810            required: true,
811        }],
812        "prettier" => vec![ToolCandidate {
813            tool: name.to_string(),
814            source: "formatter config".to_string(),
815            args: vec!["--write".to_string(), file_str.to_string()],
816            required: true,
817        }],
818        "deno" => vec![ToolCandidate {
819            tool: name.to_string(),
820            source: "formatter config".to_string(),
821            args: vec!["fmt".to_string(), file_str.to_string()],
822            required: true,
823        }],
824        "ruff" => vec![ToolCandidate {
825            tool: name.to_string(),
826            source: "formatter config".to_string(),
827            args: vec!["format".to_string(), file_str.to_string()],
828            required: true,
829        }],
830        "black" | "rustfmt" => vec![ToolCandidate {
831            tool: name.to_string(),
832            source: "formatter config".to_string(),
833            args: vec![file_str.to_string()],
834            required: true,
835        }],
836        "goimports" | "gofmt" => vec![ToolCandidate {
837            tool: name.to_string(),
838            source: "formatter config".to_string(),
839            args: vec!["-w".to_string(), file_str.to_string()],
840            required: true,
841        }],
842        _ => Vec::new(),
843    }
844}
845
846fn explicit_checker_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
847    match name {
848        "none" | "off" | "false" => Vec::new(),
849        "tsc" => vec![ToolCandidate {
850            tool: name.to_string(),
851            source: "checker config".to_string(),
852            args: vec![
853                "--noEmit".to_string(),
854                "--pretty".to_string(),
855                "false".to_string(),
856            ],
857            required: true,
858        }],
859        "cargo" => vec![ToolCandidate {
860            tool: name.to_string(),
861            source: "checker config".to_string(),
862            args: vec!["check".to_string(), "--message-format=json".to_string()],
863            required: true,
864        }],
865        "go" => vec![ToolCandidate {
866            tool: name.to_string(),
867            source: "checker config".to_string(),
868            args: vec!["vet".to_string(), file_str.to_string()],
869            required: true,
870        }],
871        "biome" => vec![ToolCandidate {
872            tool: name.to_string(),
873            source: "checker config".to_string(),
874            args: vec!["check".to_string(), file_str.to_string()],
875            required: true,
876        }],
877        "pyright" => vec![ToolCandidate {
878            tool: name.to_string(),
879            source: "checker config".to_string(),
880            args: vec!["--outputjson".to_string(), file_str.to_string()],
881            required: true,
882        }],
883        "ruff" => vec![ToolCandidate {
884            tool: name.to_string(),
885            source: "checker config".to_string(),
886            args: vec![
887                "check".to_string(),
888                "--output-format=json".to_string(),
889                file_str.to_string(),
890            ],
891            required: true,
892        }],
893        "staticcheck" => vec![ToolCandidate {
894            tool: name.to_string(),
895            source: "checker config".to_string(),
896            args: vec![file_str.to_string()],
897            required: true,
898        }],
899        _ => Vec::new(),
900    }
901}
902
903fn resolve_tool_candidates(
904    candidates: Vec<ToolCandidate>,
905    project_root: Option<&Path>,
906) -> ToolDetection {
907    if candidates.is_empty() {
908        return ToolDetection::NotConfigured;
909    }
910
911    let mut missing_required = None;
912    for candidate in candidates {
913        if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
914            return ToolDetection::Found(command, candidate.args);
915        }
916        if candidate.required && missing_required.is_none() {
917            missing_required = Some(candidate.tool);
918        }
919    }
920
921    match missing_required {
922        Some(tool) => ToolDetection::NotInstalled { tool },
923        None => ToolDetection::NotConfigured,
924    }
925}
926
927fn checker_command(candidate: &ToolCandidate, resolved: String) -> String {
928    match candidate.tool.as_str() {
929        "tsc" => resolved,
930        "cargo" => "cargo".to_string(),
931        "go" => "go".to_string(),
932        _ => resolved,
933    }
934}
935
936fn checker_args(candidate: &ToolCandidate) -> Vec<String> {
937    if candidate.tool == "tsc" {
938        vec![
939            "--noEmit".to_string(),
940            "--pretty".to_string(),
941            "false".to_string(),
942        ]
943    } else {
944        candidate.args.clone()
945    }
946}
947
948fn detect_formatter_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
949    let file_str = path.to_string_lossy().to_string();
950    resolve_tool_candidates(
951        formatter_candidates(lang, config, &file_str),
952        config.project_root.as_deref(),
953    )
954}
955
956fn detect_checker_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
957    let file_str = path.to_string_lossy().to_string();
958    let candidates = checker_candidates(lang, config, &file_str);
959    if candidates.is_empty() {
960        return ToolDetection::NotConfigured;
961    }
962
963    let project_root = config.project_root.as_deref();
964    let mut missing_required = None;
965    for candidate in candidates {
966        if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
967            return ToolDetection::Found(
968                checker_command(&candidate, command),
969                checker_args(&candidate),
970            );
971        }
972        if candidate.required && missing_required.is_none() {
973            missing_required = Some(candidate.tool);
974        }
975    }
976
977    match missing_required {
978        Some(tool) => ToolDetection::NotInstalled { tool },
979        None => ToolDetection::NotConfigured,
980    }
981}
982
983fn languages_in_project(project_root: &Path) -> HashSet<LangId> {
984    crate::callgraph::walk_project_files(project_root)
985        .filter_map(|path| detect_language(&path))
986        .collect()
987}
988
989fn placeholder_file_for_language(project_root: &Path, lang: LangId) -> PathBuf {
990    let filename = match lang {
991        LangId::TypeScript => "aft-tool-detection.ts",
992        LangId::Tsx => "aft-tool-detection.tsx",
993        LangId::JavaScript => "aft-tool-detection.js",
994        LangId::Python => "aft-tool-detection.py",
995        LangId::Rust => "aft_tool_detection.rs",
996        LangId::Go => "aft_tool_detection.go",
997        LangId::C => "aft_tool_detection.c",
998        LangId::Cpp => "aft_tool_detection.cpp",
999        LangId::Zig => "aft_tool_detection.zig",
1000        LangId::CSharp => "aft_tool_detection.cs",
1001        LangId::Bash => "aft_tool_detection.sh",
1002        LangId::Solidity => "aft_tool_detection.sol",
1003        LangId::Vue => "aft-tool-detection.vue",
1004        LangId::Json => "aft-tool-detection.json",
1005        LangId::Scala => "aft-tool-detection.scala",
1006        LangId::Java => "aft-tool-detection.java",
1007        LangId::Ruby => "aft-tool-detection.rb",
1008        LangId::Kotlin => "aft-tool-detection.kt",
1009        LangId::Swift => "aft-tool-detection.swift",
1010        LangId::Php => "aft-tool-detection.php",
1011        LangId::Lua => "aft-tool-detection.lua",
1012        LangId::Perl => "aft-tool-detection.pl",
1013        LangId::Html => "aft-tool-detection.html",
1014        LangId::Markdown => "aft-tool-detection.md",
1015    };
1016    project_root.join(filename)
1017}
1018
1019pub(crate) fn install_hint(tool: &str) -> String {
1020    match tool {
1021        "biome" => {
1022            "Run `bun add -d --workspace-root @biomejs/biome` or install globally.".to_string()
1023        }
1024        "prettier" => "Run `npm install -D prettier` or install globally.".to_string(),
1025        "tsc" => "Run `npm install -D typescript` or install globally.".to_string(),
1026        "pyright" | "pyright-langserver" => "Install: `npm install -g pyright`".to_string(),
1027        "ruff" => {
1028            "Install: `pip install ruff` or your Python package manager equivalent.".to_string()
1029        }
1030        "black" => {
1031            "Install: `pip install black` or your Python package manager equivalent.".to_string()
1032        }
1033        "rustfmt" => "Install: `rustup component add rustfmt`".to_string(),
1034        "rust-analyzer" => "Install: `rustup component add rust-analyzer`".to_string(),
1035        "cargo" => "Install Rust from https://rustup.rs/.".to_string(),
1036        "go" => [
1037            "Install Go from https://go.dev/dl/, or — if it's already installed —",
1038            "ensure its bin directory is on PATH (Homebrew typically uses",
1039            "/opt/homebrew/bin on Apple Silicon, /usr/local/bin on Intel macOS).",
1040            "GUI-launched editors often don't inherit login-shell PATH.",
1041        ]
1042        .join(" "),
1043        "gopls" => "Install: `go install golang.org/x/tools/gopls@latest`".to_string(),
1044        "bash-language-server" => "Install: `npm install -g bash-language-server`".to_string(),
1045        "yaml-language-server" => "Install: `npm install -g yaml-language-server`".to_string(),
1046        "typescript-language-server" => {
1047            "Install: `npm install -g typescript-language-server typescript`".to_string()
1048        }
1049        "deno" => "Install Deno from https://deno.com/.".to_string(),
1050        "goimports" => "Install: `go install golang.org/x/tools/cmd/goimports@latest`".to_string(),
1051        "staticcheck" => {
1052            "Install: `go install honnef.co/go/tools/cmd/staticcheck@latest`".to_string()
1053        }
1054        other => format!("Install `{other}` and ensure it is on PATH."),
1055    }
1056}
1057
1058fn configured_tool_hint(tool: &str, source: &str) -> String {
1059    // GitHub issue #47: editors launched from a non-login GUI shell (Spotlight,
1060    // Dock, Alfred, etc.) often don't inherit the user's full PATH, so a tool
1061    // that's installed but lives under /opt/homebrew/bin, ~/.cargo/bin, or
1062    // similar can fail this lookup. We already check those well-known
1063    // locations in `resolve_tool_uncached`; if we still didn't find the tool,
1064    // it's genuinely missing OR sits in an unusual install prefix.
1065    //
1066    // Word the message so users know to check both "is it installed at all"
1067    // and "is it on AFT's PATH" — rather than implying definite absence.
1068    format!(
1069        "{tool} is configured in {source} but was not found on PATH or in common install locations. {}",
1070        install_hint(tool)
1071    )
1072}
1073
1074fn missing_tool_warning(
1075    kind: &str,
1076    language: &str,
1077    candidate: &ToolCandidate,
1078    project_root: Option<&Path>,
1079) -> Option<MissingTool> {
1080    if !candidate.required || resolve_candidate_tool(candidate, project_root).is_some() {
1081        return None;
1082    }
1083
1084    Some(MissingTool {
1085        kind: kind.to_string(),
1086        language: language.to_string(),
1087        tool: candidate.tool.clone(),
1088        hint: configured_tool_hint(&candidate.tool, &candidate.source),
1089    })
1090}
1091
1092/// Detect configured formatters/checkers that are missing for languages present in the project.
1093pub fn detect_missing_tools(project_root: &Path, config: &Config) -> Vec<MissingTool> {
1094    let languages = languages_in_project(project_root);
1095    let mut warnings = Vec::new();
1096    let mut seen = HashSet::new();
1097
1098    for lang in languages {
1099        let language = lang_key(lang);
1100        let placeholder = placeholder_file_for_language(project_root, lang);
1101        let file_str = placeholder.to_string_lossy().to_string();
1102
1103        for candidate in formatter_candidates(lang, config, &file_str) {
1104            if let Some(warning) = missing_tool_warning(
1105                "formatter_not_installed",
1106                language,
1107                &candidate,
1108                config.project_root.as_deref(),
1109            ) {
1110                if seen.insert((
1111                    warning.kind.clone(),
1112                    warning.language.clone(),
1113                    warning.tool.clone(),
1114                )) {
1115                    warnings.push(warning);
1116                }
1117            }
1118        }
1119
1120        for candidate in checker_candidates(lang, config, &file_str) {
1121            if let Some(warning) = missing_tool_warning(
1122                "checker_not_installed",
1123                language,
1124                &candidate,
1125                config.project_root.as_deref(),
1126            ) {
1127                if seen.insert((
1128                    warning.kind.clone(),
1129                    warning.language.clone(),
1130                    warning.tool.clone(),
1131                )) {
1132                    warnings.push(warning);
1133                }
1134            }
1135        }
1136    }
1137
1138    warnings.sort_by(|left, right| {
1139        (&left.kind, &left.language, &left.tool).cmp(&(&right.kind, &right.language, &right.tool))
1140    });
1141    warnings
1142}
1143
1144/// Detect the appropriate formatter command and arguments for a file.
1145///
1146/// Priority per language:
1147/// - TypeScript/JavaScript/TSX: `prettier --write <file>`
1148/// - Python: `ruff format <file>` (fallback: `black <file>`)
1149/// - Rust: `rustfmt <file>`
1150/// - Go: `gofmt -w <file>`
1151///
1152/// Returns `None` if no formatter is available for the language.
1153pub fn detect_formatter(
1154    path: &Path,
1155    lang: LangId,
1156    config: &Config,
1157) -> Option<(String, Vec<String>)> {
1158    match detect_formatter_for_path(path, lang, config) {
1159        ToolDetection::Found(cmd, args) => Some((cmd, args)),
1160        ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1161    }
1162}
1163
1164/// Check if any of the given config file names exist in the project root.
1165fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
1166    let root = match project_root {
1167        Some(r) => r,
1168        None => return false,
1169    };
1170    filenames.iter().any(|f| root.join(f).exists())
1171}
1172
1173/// Check if pyproject.toml exists and contains a `[tool.<name>]` section.
1174fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
1175    let root = match project_root {
1176        Some(r) => r,
1177        None => return false,
1178    };
1179    let pyproject = root.join("pyproject.toml");
1180    if !pyproject.exists() {
1181        return false;
1182    }
1183    match std::fs::read_to_string(&pyproject) {
1184        Ok(content) => {
1185            let pattern = format!("[tool.{}]", tool_name);
1186            content.contains(&pattern)
1187        }
1188        Err(_) => false,
1189    }
1190}
1191
1192/// Detect whether a non-zero formatter exit was caused by the formatter
1193/// intentionally excluding the path (per its own config) rather than an
1194/// actual formatter or input error.
1195///
1196/// The patterns below come from real stderr output observed during
1197/// dogfooding. They're intentionally substring-based and case-insensitive
1198/// so minor formatter version differences in wording don't bypass the
1199/// check. Each pattern corresponds to a specific formatter's exclusion
1200/// signal:
1201/// - biome: `"No files were processed in the specified paths."`,
1202///   `"ignored by the configuration"`
1203/// - prettier: `"No files matching the pattern were found"`
1204/// - ruff: `"No Python files found under the given path(s)"`
1205///
1206/// rustfmt and gofmt/goimports rarely scope-restrict and have no known
1207/// stable marker, so they're not detected here. They'll fall through to
1208/// the generic `"error"` reason — acceptable because they almost never
1209/// emit a path-exclusion exit in practice.
1210fn formatter_excluded_path(stderr: &str) -> bool {
1211    let s = stderr.to_lowercase();
1212    s.contains("no files were processed")
1213        || s.contains("ignored by the configuration")
1214        || s.contains("no files matching the pattern")
1215        || s.contains("no python files found")
1216}
1217
1218/// Auto-format a file using the detected formatter for its language.
1219///
1220/// Returns `(formatted, skip_reason)`:
1221/// - `(true, None)` — file was successfully formatted
1222/// - `(false, Some(reason))` — formatting was skipped, reason explains why
1223///
1224/// Skip reasons:
1225/// - `"unsupported_language"` — language has no formatter support in AFT
1226/// - `"no_formatter_configured"` — `format_on_edit=false` or no formatter
1227///   detected for the language in the project
1228/// - `"formatter_not_installed"` — configured formatter binary missing on
1229///   PATH and not in project's `node_modules/.bin`
1230/// - `"formatter_excluded_path"` — formatter ran but refused to process this
1231///   path because the project formatter config (e.g. biome.json `files.includes`,
1232///   prettier `.prettierignore`) excludes it. NOT an error in AFT or the user's
1233///   formatter — the user told the formatter not to touch this path. Agents
1234///   should treat this as informational.
1235/// - `"timeout"` — formatter exceeded `formatter_timeout_secs`
1236/// - `"error"` — formatter exited non-zero with an unrecognized error
1237///   (likely a real bug in the user's input or the formatter itself)
1238pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
1239    // Check if formatting is disabled via plugin config
1240    if !config.format_on_edit {
1241        return (false, Some("no_formatter_configured".to_string()));
1242    }
1243
1244    let lang = match detect_language(path) {
1245        Some(l) => l,
1246        None => {
1247            log::debug!("format: {} (skipped: unsupported_language)", path.display());
1248            return (false, Some("unsupported_language".to_string()));
1249        }
1250    };
1251    if !has_formatter_support(lang) {
1252        log::debug!("format: {} (skipped: unsupported_language)", path.display());
1253        return (false, Some("unsupported_language".to_string()));
1254    }
1255
1256    let (cmd, args) = match detect_formatter_for_path(path, lang, config) {
1257        ToolDetection::Found(cmd, args) => (cmd, args),
1258        ToolDetection::NotConfigured => {
1259            log::debug!(
1260                "format: {} (skipped: no_formatter_configured)",
1261                path.display()
1262            );
1263            return (false, Some("no_formatter_configured".to_string()));
1264        }
1265        ToolDetection::NotInstalled { tool } => {
1266            crate::slog_warn!(
1267                "format: {} (skipped: formatter_not_installed: {})",
1268                path.display(),
1269                tool
1270            );
1271            return (false, Some("formatter_not_installed".to_string()));
1272        }
1273    };
1274
1275    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1276
1277    // Run the formatter in the project root so tool-local config files
1278    // (biome.json, .prettierrc, rustfmt.toml, etc.) are discovered. The
1279    // type-checker path (`validate_full`) already does this via
1280    // `path.parent()`; formatters need the same treatment. Without it,
1281    // formatters silently fall back to built-in defaults when the aft
1282    // process CWD differs from the project root (audit #18).
1283    let working_dir = config.project_root.as_deref();
1284
1285    match run_external_tool(&cmd, &arg_refs, working_dir, config.formatter_timeout_secs) {
1286        Ok(_) => {
1287            crate::slog_info!("format: {} ({})", path.display(), cmd);
1288            (true, None)
1289        }
1290        Err(FormatError::Timeout { .. }) => {
1291            crate::slog_warn!("format: {} (skipped: timeout)", path.display());
1292            (false, Some("timeout".to_string()))
1293        }
1294        Err(FormatError::NotFound { .. }) => {
1295            crate::slog_warn!(
1296                "format: {} (skipped: formatter_not_installed)",
1297                path.display()
1298            );
1299            (false, Some("formatter_not_installed".to_string()))
1300        }
1301        Err(FormatError::Failed { stderr, .. }) => {
1302            // Distinguish "formatter intentionally ignored this path" from
1303            // "formatter actually errored". Many formatters scope themselves
1304            // to a project subtree (biome.json `files.includes`, prettier
1305            // `.prettierignore`, ruff `[tool.ruff]` config) and exit non-zero
1306            // when invoked on a path outside that scope. From AFT's perspective
1307            // that's not an error — the user told the formatter not to touch
1308            // this path. But the previous code returned a generic `"error"`
1309            // skip reason and logged at `debug` (silent under default
1310            // RUST_LOG=info), so the agent had no signal that the file
1311            // landed unformatted. Detect the common stderr fingerprints and
1312            // return a distinct, surfaced skip reason.
1313            if formatter_excluded_path(&stderr) {
1314                crate::slog_info!(
1315                    "format: {} (skipped: formatter_excluded_path; stderr: {})",
1316                    path.display(),
1317                    stderr.lines().next().unwrap_or("").trim()
1318                );
1319                return (false, Some("formatter_excluded_path".to_string()));
1320            }
1321            crate::slog_warn!(
1322                "format: {} (skipped: error: {})",
1323                path.display(),
1324                stderr.lines().next().unwrap_or("unknown").trim()
1325            );
1326            (false, Some("error".to_string()))
1327        }
1328        Err(FormatError::UnsupportedLanguage) => {
1329            log::debug!("format: {} (skipped: unsupported_language)", path.display());
1330            (false, Some("unsupported_language".to_string()))
1331        }
1332    }
1333}
1334
1335/// Spawn a subprocess and capture output regardless of exit code.
1336///
1337/// Unlike `run_external_tool`, this does NOT treat non-zero exit as an error —
1338/// type checkers return non-zero when they find issues, which is expected.
1339/// Returns `FormatError::NotFound` when the binary isn't on PATH, and
1340/// `FormatError::Timeout` if the deadline is exceeded.
1341pub fn run_external_tool_capture(
1342    command: &str,
1343    args: &[&str],
1344    working_dir: Option<&Path>,
1345    timeout_secs: u32,
1346) -> Result<ExternalToolResult, FormatError> {
1347    let mut cmd = Command::new(command);
1348    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
1349
1350    if let Some(dir) = working_dir {
1351        cmd.current_dir(dir);
1352    }
1353
1354    isolate_in_process_group(&mut cmd);
1355
1356    let child = match cmd.spawn() {
1357        Ok(c) => c,
1358        Err(e) if e.kind() == ErrorKind::NotFound => {
1359            return Err(FormatError::NotFound {
1360                tool: command.to_string(),
1361            });
1362        }
1363        Err(e) => {
1364            return Err(FormatError::Failed {
1365                tool: command.to_string(),
1366                stderr: e.to_string(),
1367            });
1368        }
1369    };
1370
1371    let outcome = wait_with_timeout(child, command, timeout_secs)?;
1372    Ok(ExternalToolResult {
1373        stdout: outcome.stdout,
1374        stderr: outcome.stderr,
1375        exit_code: outcome.status.code().unwrap_or(-1),
1376    })
1377}
1378
1379// ============================================================================
1380// Type-checker validation (R017)
1381// ============================================================================
1382
1383/// A structured error from a type checker.
1384#[derive(Debug, Clone, serde::Serialize)]
1385pub struct ValidationError {
1386    pub line: u32,
1387    pub column: u32,
1388    pub message: String,
1389    pub severity: String,
1390}
1391
1392/// Detect the appropriate type checker command and arguments for a file.
1393///
1394/// Returns `(command, args)` for the type checker. The `--noEmit` / equivalent
1395/// flags ensure no output files are produced.
1396///
1397/// Supported:
1398/// - TypeScript/JavaScript/TSX → `npx tsc --noEmit` (fallback: `tsc --noEmit`)
1399/// - Python → `pyright`
1400/// - Rust → `cargo check`
1401/// - Go → `go vet`
1402pub fn detect_type_checker(
1403    path: &Path,
1404    lang: LangId,
1405    config: &Config,
1406) -> Option<(String, Vec<String>)> {
1407    match detect_checker_for_path(path, lang, config) {
1408        ToolDetection::Found(cmd, args) => Some((cmd, args)),
1409        ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1410    }
1411}
1412
1413/// Parse type checker output into structured validation errors.
1414///
1415/// Handles output formats from tsc, pyright (JSON), cargo check (JSON), and go vet.
1416/// Filters to errors related to the edited file where feasible.
1417pub fn parse_checker_output(
1418    stdout: &str,
1419    stderr: &str,
1420    file: &Path,
1421    checker: &str,
1422) -> Vec<ValidationError> {
1423    let checker_name = Path::new(checker)
1424        .file_name()
1425        .and_then(|name| name.to_str())
1426        .unwrap_or(checker);
1427    match checker_name {
1428        "npx" | "tsc" => parse_tsc_output(stdout, stderr, file),
1429        "pyright" => parse_pyright_output(stdout, file),
1430        "cargo" => parse_cargo_output(stdout, stderr, file),
1431        "go" => parse_go_vet_output(stderr, file),
1432        _ => Vec::new(),
1433    }
1434}
1435
1436/// Parse tsc output lines like: `path(line,col): error TSxxxx: message`
1437fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1438    let mut errors = Vec::new();
1439    let file_str = file.to_string_lossy();
1440    // tsc writes diagnostics to stdout (with --pretty false)
1441    let combined = format!("{}{}", stdout, stderr);
1442    for line in combined.lines() {
1443        // Format: path(line,col): severity TSxxxx: message
1444        // or: path(line,col): severity: message
1445        if let Some((loc, rest)) = line.split_once("): ") {
1446            // Check if this error is for our file (compare filename part)
1447            let file_part = loc.split('(').next().unwrap_or("");
1448            if !file_str.ends_with(file_part)
1449                && !file_part.ends_with(&*file_str)
1450                && file_part != &*file_str
1451            {
1452                continue;
1453            }
1454
1455            // Parse (line,col) from the location part
1456            let coords = loc.split('(').last().unwrap_or("");
1457            let parts: Vec<&str> = coords.split(',').collect();
1458            let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
1459            let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1460
1461            // Parse severity and message
1462            let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
1463                ("error".to_string(), msg.to_string())
1464            } else if let Some(msg) = rest.strip_prefix("warning ") {
1465                ("warning".to_string(), msg.to_string())
1466            } else {
1467                ("error".to_string(), rest.to_string())
1468            };
1469
1470            errors.push(ValidationError {
1471                line: line_num,
1472                column: col_num,
1473                message,
1474                severity,
1475            });
1476        }
1477    }
1478    errors
1479}
1480
1481/// Parse pyright JSON output.
1482fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
1483    let mut errors = Vec::new();
1484    let file_str = file.to_string_lossy();
1485
1486    // pyright --outputjson emits JSON with generalDiagnostics array
1487    if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
1488        if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
1489            for diag in diags {
1490                // Filter to our file
1491                let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
1492                if !diag_file.is_empty()
1493                    && !file_str.ends_with(diag_file)
1494                    && !diag_file.ends_with(&*file_str)
1495                    && diag_file != &*file_str
1496                {
1497                    continue;
1498                }
1499
1500                let line_num = diag
1501                    .get("range")
1502                    .and_then(|r| r.get("start"))
1503                    .and_then(|s| s.get("line"))
1504                    .and_then(|l| l.as_u64())
1505                    .unwrap_or(0) as u32;
1506                let col_num = diag
1507                    .get("range")
1508                    .and_then(|r| r.get("start"))
1509                    .and_then(|s| s.get("character"))
1510                    .and_then(|c| c.as_u64())
1511                    .unwrap_or(0) as u32;
1512                let message = diag
1513                    .get("message")
1514                    .and_then(|m| m.as_str())
1515                    .unwrap_or("unknown error")
1516                    .to_string();
1517                let severity = diag
1518                    .get("severity")
1519                    .and_then(|s| s.as_str())
1520                    .unwrap_or("error")
1521                    .to_lowercase();
1522
1523                errors.push(ValidationError {
1524                    line: line_num + 1, // pyright uses 0-indexed lines
1525                    column: col_num,
1526                    message,
1527                    severity,
1528                });
1529            }
1530        }
1531    }
1532    errors
1533}
1534
1535/// Parse cargo check JSON output, filtering to errors in the target file.
1536fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
1537    let mut errors = Vec::new();
1538    let file_str = file.to_string_lossy();
1539
1540    for line in stdout.lines() {
1541        if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
1542            if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
1543                continue;
1544            }
1545            let message_obj = match msg.get("message") {
1546                Some(m) => m,
1547                None => continue,
1548            };
1549
1550            let level = message_obj
1551                .get("level")
1552                .and_then(|l| l.as_str())
1553                .unwrap_or("error");
1554
1555            // Only include errors and warnings, skip notes/help
1556            if level != "error" && level != "warning" {
1557                continue;
1558            }
1559
1560            let text = message_obj
1561                .get("message")
1562                .and_then(|m| m.as_str())
1563                .unwrap_or("unknown error")
1564                .to_string();
1565
1566            // Find the primary span for our file
1567            if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
1568                for span in spans {
1569                    let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
1570                    let is_primary = span
1571                        .get("is_primary")
1572                        .and_then(|p| p.as_bool())
1573                        .unwrap_or(false);
1574
1575                    if !is_primary {
1576                        continue;
1577                    }
1578
1579                    // Filter to our file
1580                    if !file_str.ends_with(span_file)
1581                        && !span_file.ends_with(&*file_str)
1582                        && span_file != &*file_str
1583                    {
1584                        continue;
1585                    }
1586
1587                    let line_num =
1588                        span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
1589                    let col_num = span
1590                        .get("column_start")
1591                        .and_then(|c| c.as_u64())
1592                        .unwrap_or(0) as u32;
1593
1594                    errors.push(ValidationError {
1595                        line: line_num,
1596                        column: col_num,
1597                        message: text.clone(),
1598                        severity: level.to_string(),
1599                    });
1600                }
1601            }
1602        }
1603    }
1604    errors
1605}
1606
1607/// Parse go vet output lines like: `path:line:col: message`
1608fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
1609    let mut errors = Vec::new();
1610    let file_str = file.to_string_lossy();
1611
1612    for line in stderr.lines() {
1613        // Format: path:line:col: message  OR  path:line: message
1614        let parts: Vec<&str> = line.splitn(4, ':').collect();
1615        if parts.len() < 3 {
1616            continue;
1617        }
1618
1619        let err_file = parts[0].trim();
1620        if !file_str.ends_with(err_file)
1621            && !err_file.ends_with(&*file_str)
1622            && err_file != &*file_str
1623        {
1624            continue;
1625        }
1626
1627        let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
1628        let (col_num, message) = if parts.len() >= 4 {
1629            if let Ok(col) = parts[2].trim().parse::<u32>() {
1630                (col, parts[3].trim().to_string())
1631            } else {
1632                // parts[2] is part of the message, not a column
1633                (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
1634            }
1635        } else {
1636            (0, parts[2].trim().to_string())
1637        };
1638
1639        errors.push(ValidationError {
1640            line: line_num,
1641            column: col_num,
1642            message,
1643            severity: "error".to_string(),
1644        });
1645    }
1646    errors
1647}
1648
1649/// Run the project's type checker and return structured validation errors.
1650///
1651/// Returns `(errors, skip_reason)`:
1652/// - `(errors, None)` — checker ran, errors may be empty (= valid code)
1653/// - `([], Some(reason))` — checker was skipped
1654///
1655/// Skip reasons: `"unsupported_language"`, `"no_checker_configured"`,
1656/// `"checker_not_installed"`, `"timeout"`, `"error"`
1657pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
1658    let lang = match detect_language(path) {
1659        Some(l) => l,
1660        None => {
1661            log::debug!(
1662                "validate: {} (skipped: unsupported_language)",
1663                path.display()
1664            );
1665            return (Vec::new(), Some("unsupported_language".to_string()));
1666        }
1667    };
1668    if !has_checker_support(lang) {
1669        log::debug!(
1670            "validate: {} (skipped: unsupported_language)",
1671            path.display()
1672        );
1673        return (Vec::new(), Some("unsupported_language".to_string()));
1674    }
1675
1676    let (cmd, args) = match detect_checker_for_path(path, lang, config) {
1677        ToolDetection::Found(cmd, args) => (cmd, args),
1678        ToolDetection::NotConfigured => {
1679            log::debug!(
1680                "validate: {} (skipped: no_checker_configured)",
1681                path.display()
1682            );
1683            return (Vec::new(), Some("no_checker_configured".to_string()));
1684        }
1685        ToolDetection::NotInstalled { tool } => {
1686            crate::slog_warn!(
1687                "validate: {} (skipped: checker_not_installed: {})",
1688                path.display(),
1689                tool
1690            );
1691            return (Vec::new(), Some("checker_not_installed".to_string()));
1692        }
1693    };
1694
1695    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1696
1697    // Type checkers may need to run from the project root
1698    let working_dir = config.project_root.as_deref();
1699
1700    match run_external_tool_capture(
1701        &cmd,
1702        &arg_refs,
1703        working_dir,
1704        config.type_checker_timeout_secs,
1705    ) {
1706        Ok(result) => {
1707            let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
1708            log::debug!(
1709                "validate: {} ({}, {} errors)",
1710                path.display(),
1711                cmd,
1712                errors.len()
1713            );
1714            (errors, None)
1715        }
1716        Err(FormatError::Timeout { .. }) => {
1717            crate::slog_error!("validate: {} (skipped: timeout)", path.display());
1718            (Vec::new(), Some("timeout".to_string()))
1719        }
1720        Err(FormatError::NotFound { .. }) => {
1721            crate::slog_warn!(
1722                "validate: {} (skipped: checker_not_installed)",
1723                path.display()
1724            );
1725            (Vec::new(), Some("checker_not_installed".to_string()))
1726        }
1727        Err(FormatError::Failed { stderr, .. }) => {
1728            log::debug!(
1729                "validate: {} (skipped: error: {})",
1730                path.display(),
1731                stderr.lines().next().unwrap_or("unknown")
1732            );
1733            (Vec::new(), Some("error".to_string()))
1734        }
1735        Err(FormatError::UnsupportedLanguage) => {
1736            log::debug!(
1737                "validate: {} (skipped: unsupported_language)",
1738                path.display()
1739            );
1740            (Vec::new(), Some("unsupported_language".to_string()))
1741        }
1742    }
1743}
1744
1745#[cfg(test)]
1746mod tests {
1747    use super::*;
1748    use std::fs;
1749    use std::io::Write;
1750    use std::sync::{Mutex, MutexGuard, OnceLock};
1751
1752    /// Serializes tests that mutate the global TOOL_RESOLUTION_CACHE /
1753    /// TOOL_AVAILABILITY_CACHE. Cargo runs tests in parallel by default, and
1754    /// `clear_tool_cache()` from one test would otherwise wipe cached entries
1755    /// that another test had just written, causing flaky CI failures (the
1756    /// `resolve_tool_caches_negative_result_until_clear` failure on Linux
1757    /// runners had exactly this shape).
1758    fn tool_cache_test_lock() -> MutexGuard<'static, ()> {
1759        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1760        let mutex = LOCK.get_or_init(|| Mutex::new(()));
1761        // Recover from poisoning so a panic in one test doesn't permanently
1762        // wedge the rest of the suite.
1763        match mutex.lock() {
1764            Ok(guard) => guard,
1765            Err(poisoned) => poisoned.into_inner(),
1766        }
1767    }
1768
1769    #[test]
1770    fn run_external_tool_not_found() {
1771        let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1772        assert!(result.is_err());
1773        match result.unwrap_err() {
1774            FormatError::NotFound { tool } => {
1775                assert_eq!(tool, "__nonexistent_tool_xyz__");
1776            }
1777            other => panic!("expected NotFound, got: {:?}", other),
1778        }
1779    }
1780
1781    #[test]
1782    fn run_external_tool_timeout_kills_subprocess() {
1783        // Use `sleep 60` as a long-running process, timeout after 1 second
1784        let result = run_external_tool("sleep", &["60"], None, 1);
1785        assert!(result.is_err());
1786        match result.unwrap_err() {
1787            FormatError::Timeout { tool, timeout_secs } => {
1788                assert_eq!(tool, "sleep");
1789                assert_eq!(timeout_secs, 1);
1790            }
1791            other => panic!("expected Timeout, got: {:?}", other),
1792        }
1793    }
1794
1795    #[test]
1796    fn run_external_tool_success() {
1797        let result = run_external_tool("echo", &["hello"], None, 5);
1798        assert!(result.is_ok());
1799        let res = result.unwrap();
1800        assert_eq!(res.exit_code, 0);
1801        assert!(res.stdout.contains("hello"));
1802    }
1803
1804    #[cfg(unix)]
1805    #[test]
1806    fn format_helper_handles_large_stderr_without_deadlock() {
1807        let start = Instant::now();
1808        let result = run_external_tool_capture(
1809            "sh",
1810            &[
1811                "-c",
1812                "i=0; while [ $i -lt 1024 ]; do printf '%1024s\\n' x >&2; i=$((i+1)); done",
1813            ],
1814            None,
1815            2,
1816        )
1817        .expect("large stderr command should complete");
1818
1819        assert_eq!(result.exit_code, 0);
1820        assert!(
1821            result.stderr.len() >= 1024 * 1024,
1822            "expected full stderr capture, got {} bytes",
1823            result.stderr.len()
1824        );
1825        assert!(start.elapsed() < Duration::from_secs(2));
1826    }
1827
1828    #[test]
1829    fn run_external_tool_nonzero_exit() {
1830        // `false` always exits with code 1
1831        let result = run_external_tool("false", &[], None, 5);
1832        assert!(result.is_err());
1833        match result.unwrap_err() {
1834            FormatError::Failed { tool, .. } => {
1835                assert_eq!(tool, "false");
1836            }
1837            other => panic!("expected Failed, got: {:?}", other),
1838        }
1839    }
1840
1841    #[test]
1842    fn auto_format_unsupported_language() {
1843        let dir = tempfile::tempdir().unwrap();
1844        let path = dir.path().join("file.txt");
1845        fs::write(&path, "hello").unwrap();
1846
1847        let config = Config::default();
1848        let (formatted, reason) = auto_format(&path, &config);
1849        assert!(!formatted);
1850        assert_eq!(reason.as_deref(), Some("unsupported_language"));
1851    }
1852
1853    #[test]
1854    fn detect_formatter_rust_when_rustfmt_available() {
1855        let dir = tempfile::tempdir().unwrap();
1856        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1857        let path = dir.path().join("test.rs");
1858        let config = Config {
1859            project_root: Some(dir.path().to_path_buf()),
1860            ..Config::default()
1861        };
1862        let result = detect_formatter(&path, LangId::Rust, &config);
1863        if resolve_tool("rustfmt", config.project_root.as_deref()).is_some() {
1864            let (cmd, args) = result.unwrap();
1865            assert_eq!(cmd, "rustfmt");
1866            assert!(args.iter().any(|a| a.ends_with("test.rs")));
1867        } else {
1868            assert!(result.is_none());
1869        }
1870    }
1871
1872    #[test]
1873    fn detect_formatter_go_mapping() {
1874        let dir = tempfile::tempdir().unwrap();
1875        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1876        let path = dir.path().join("main.go");
1877        let config = Config {
1878            project_root: Some(dir.path().to_path_buf()),
1879            ..Config::default()
1880        };
1881        let result = detect_formatter(&path, LangId::Go, &config);
1882        if resolve_tool("goimports", config.project_root.as_deref()).is_some() {
1883            let (cmd, args) = result.unwrap();
1884            assert_eq!(cmd, "goimports");
1885            assert!(args.contains(&"-w".to_string()));
1886        } else if resolve_tool("gofmt", config.project_root.as_deref()).is_some() {
1887            let (cmd, args) = result.unwrap();
1888            assert_eq!(cmd, "gofmt");
1889            assert!(args.contains(&"-w".to_string()));
1890        } else {
1891            assert!(result.is_none());
1892        }
1893    }
1894
1895    #[test]
1896    fn detect_formatter_python_mapping() {
1897        let dir = tempfile::tempdir().unwrap();
1898        fs::write(dir.path().join("ruff.toml"), "").unwrap();
1899        let path = dir.path().join("main.py");
1900        let config = Config {
1901            project_root: Some(dir.path().to_path_buf()),
1902            ..Config::default()
1903        };
1904        let result = detect_formatter(&path, LangId::Python, &config);
1905        if ruff_format_available(config.project_root.as_deref()) {
1906            let (cmd, args) = result.unwrap();
1907            assert_eq!(cmd, "ruff");
1908            assert!(args.contains(&"format".to_string()));
1909        } else {
1910            assert!(result.is_none());
1911        }
1912    }
1913
1914    #[test]
1915    fn detect_formatter_no_config_returns_none() {
1916        let path = Path::new("test.ts");
1917        let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1918        assert!(
1919            result.is_none(),
1920            "expected no formatter without project config"
1921        );
1922    }
1923
1924    // Unix-only: `resolve_tool_uncached` checks `node_modules/.bin/<name>`
1925    // without trying Windows extensions (.cmd/.exe/.bat). Writing
1926    // `biome.cmd` would not be found by the resolver. A future product
1927    // fix could extend resolve_tool to honor PATHEXT; for now this test
1928    // focuses on the explicit-override semantics on Unix.
1929    #[cfg(unix)]
1930    #[test]
1931    fn detect_formatter_explicit_override() {
1932        // Create a temp dir with a fake node_modules/.bin/biome so resolve_tool finds it
1933        let dir = tempfile::tempdir().unwrap();
1934        let bin_dir = dir.path().join("node_modules").join(".bin");
1935        fs::create_dir_all(&bin_dir).unwrap();
1936        use std::os::unix::fs::PermissionsExt;
1937        let fake = bin_dir.join("biome");
1938        fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
1939        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
1940
1941        let path = Path::new("test.ts");
1942        let mut config = Config {
1943            project_root: Some(dir.path().to_path_buf()),
1944            ..Config::default()
1945        };
1946        config
1947            .formatter
1948            .insert("typescript".to_string(), "biome".to_string());
1949        let result = detect_formatter(path, LangId::TypeScript, &config);
1950        let (cmd, args) = result.unwrap();
1951        assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
1952        assert!(args.contains(&"format".to_string()));
1953        assert!(args.contains(&"--write".to_string()));
1954    }
1955
1956    #[test]
1957    fn resolve_tool_caches_positive_result_until_clear() {
1958        let _guard = tool_cache_test_lock();
1959        clear_tool_cache();
1960        let dir = tempfile::tempdir().unwrap();
1961        let bin_dir = dir.path().join("node_modules").join(".bin");
1962        fs::create_dir_all(&bin_dir).unwrap();
1963        let tool = bin_dir.join("aft-cache-hit-tool");
1964        fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
1965
1966        let first = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
1967        assert_eq!(first.as_deref(), Some(tool.to_string_lossy().as_ref()));
1968
1969        fs::remove_file(&tool).unwrap();
1970        let cached = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
1971        assert_eq!(cached, first);
1972
1973        clear_tool_cache();
1974        assert!(resolve_tool("aft-cache-hit-tool", Some(dir.path())).is_none());
1975    }
1976
1977    #[test]
1978    fn resolve_tool_caches_negative_result_until_clear() {
1979        let _guard = tool_cache_test_lock();
1980        clear_tool_cache();
1981        let dir = tempfile::tempdir().unwrap();
1982        let bin_dir = dir.path().join("node_modules").join(".bin");
1983        let tool = bin_dir.join("aft-cache-miss-tool");
1984
1985        assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
1986
1987        fs::create_dir_all(&bin_dir).unwrap();
1988        fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
1989        assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
1990
1991        clear_tool_cache();
1992        assert_eq!(
1993            resolve_tool("aft-cache-miss-tool", Some(dir.path())).as_deref(),
1994            Some(tool.to_string_lossy().as_ref())
1995        );
1996    }
1997
1998    #[test]
1999    fn auto_format_happy_path_rustfmt() {
2000        if resolve_tool("rustfmt", None).is_none() {
2001            crate::slog_warn!("skipping: rustfmt not available");
2002            return;
2003        }
2004
2005        let dir = tempfile::tempdir().unwrap();
2006        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2007        let path = dir.path().join("test.rs");
2008
2009        let mut f = fs::File::create(&path).unwrap();
2010        writeln!(f, "fn    main()   {{  println!(\"hello\");  }}").unwrap();
2011        drop(f);
2012
2013        let config = Config {
2014            project_root: Some(dir.path().to_path_buf()),
2015            ..Config::default()
2016        };
2017        let (formatted, reason) = auto_format(&path, &config);
2018        assert!(formatted, "expected formatting to succeed");
2019        assert!(reason.is_none());
2020
2021        let content = fs::read_to_string(&path).unwrap();
2022        assert!(
2023            !content.contains("fn    main"),
2024            "expected rustfmt to fix spacing"
2025        );
2026    }
2027
2028    #[test]
2029    fn formatter_excluded_path_detects_biome_messages() {
2030        // Real biome 1.x output when invoked on a path outside files.includes.
2031        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";
2032        assert!(
2033            formatter_excluded_path(stderr),
2034            "expected biome exclusion stderr to be detected"
2035        );
2036    }
2037
2038    #[test]
2039    fn formatter_excluded_path_detects_prettier_messages() {
2040        // Real prettier output when given a glob/path that resolves to nothing
2041        // it's allowed to format (after .prettierignore filtering).
2042        let stderr = "[error] No files matching the pattern were found: \"src/scratch.ts\".\n";
2043        assert!(
2044            formatter_excluded_path(stderr),
2045            "expected prettier exclusion stderr to be detected"
2046        );
2047    }
2048
2049    #[test]
2050    fn formatter_excluded_path_detects_ruff_messages() {
2051        // Real ruff output when invoked outside its [tool.ruff] scope.
2052        let stderr = "warning: No Python files found under the given path(s).\n";
2053        assert!(
2054            formatter_excluded_path(stderr),
2055            "expected ruff exclusion stderr to be detected"
2056        );
2057    }
2058
2059    #[test]
2060    fn formatter_excluded_path_is_case_insensitive() {
2061        assert!(formatter_excluded_path("NO FILES WERE PROCESSED"));
2062        assert!(formatter_excluded_path("Ignored By The Configuration"));
2063    }
2064
2065    #[test]
2066    fn formatter_excluded_path_rejects_real_errors() {
2067        // Counter-cases: actual formatter errors must NOT be treated as
2068        // exclusion. This guards against the detection being too greedy.
2069        assert!(!formatter_excluded_path(""));
2070        assert!(!formatter_excluded_path("syntax error: unexpected token"));
2071        assert!(!formatter_excluded_path("formatter crashed: out of memory"));
2072        assert!(!formatter_excluded_path(
2073            "permission denied: /readonly/file"
2074        ));
2075        assert!(!formatter_excluded_path(
2076            "biome internal error: please report"
2077        ));
2078    }
2079
2080    #[test]
2081    fn parse_tsc_output_basic() {
2082        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";
2083        let file = Path::new("src/app.ts");
2084        let errors = parse_tsc_output(stdout, "", file);
2085        assert_eq!(errors.len(), 2);
2086        assert_eq!(errors[0].line, 10);
2087        assert_eq!(errors[0].column, 5);
2088        assert_eq!(errors[0].severity, "error");
2089        assert!(errors[0].message.contains("TS2322"));
2090        assert_eq!(errors[1].line, 20);
2091    }
2092
2093    #[test]
2094    fn parse_tsc_output_filters_other_files() {
2095        let stdout =
2096            "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
2097        let file = Path::new("src/app.ts");
2098        let errors = parse_tsc_output(stdout, "", file);
2099        assert_eq!(errors.len(), 1);
2100        assert_eq!(errors[0].line, 5);
2101    }
2102
2103    #[test]
2104    fn parse_cargo_output_basic() {
2105        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}]}}"#;
2106        let file = Path::new("src/main.rs");
2107        let errors = parse_cargo_output(json_line, "", file);
2108        assert_eq!(errors.len(), 1);
2109        assert_eq!(errors[0].line, 10);
2110        assert_eq!(errors[0].column, 5);
2111        assert_eq!(errors[0].severity, "error");
2112        assert!(errors[0].message.contains("mismatched types"));
2113    }
2114
2115    #[test]
2116    fn parse_cargo_output_skips_notes() {
2117        // Notes and help messages should be filtered out
2118        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}]}}"#;
2119        let file = Path::new("src/main.rs");
2120        let errors = parse_cargo_output(json_line, "", file);
2121        assert_eq!(errors.len(), 0);
2122    }
2123
2124    #[test]
2125    fn parse_cargo_output_filters_other_files() {
2126        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}]}}"#;
2127        let file = Path::new("src/main.rs");
2128        let errors = parse_cargo_output(json_line, "", file);
2129        assert_eq!(errors.len(), 0);
2130    }
2131
2132    #[test]
2133    fn parse_go_vet_output_basic() {
2134        let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
2135        let file = Path::new("main.go");
2136        let errors = parse_go_vet_output(stderr, file);
2137        assert_eq!(errors.len(), 2);
2138        assert_eq!(errors[0].line, 10);
2139        assert_eq!(errors[0].column, 5);
2140        assert!(errors[0].message.contains("unreachable code"));
2141        assert_eq!(errors[1].line, 20);
2142        assert_eq!(errors[1].column, 0);
2143    }
2144
2145    #[test]
2146    fn parse_pyright_output_basic() {
2147        let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
2148        let file = Path::new("test.py");
2149        let errors = parse_pyright_output(stdout, file);
2150        assert_eq!(errors.len(), 1);
2151        assert_eq!(errors[0].line, 5); // 0-indexed → 1-indexed
2152        assert_eq!(errors[0].column, 10);
2153        assert_eq!(errors[0].severity, "error");
2154        assert!(errors[0].message.contains("Type error here"));
2155    }
2156
2157    #[test]
2158    fn validate_full_unsupported_language() {
2159        let dir = tempfile::tempdir().unwrap();
2160        let path = dir.path().join("file.txt");
2161        fs::write(&path, "hello").unwrap();
2162
2163        let config = Config::default();
2164        let (errors, reason) = validate_full(&path, &config);
2165        assert!(errors.is_empty());
2166        assert_eq!(reason.as_deref(), Some("unsupported_language"));
2167    }
2168
2169    #[test]
2170    fn detect_type_checker_rust() {
2171        let dir = tempfile::tempdir().unwrap();
2172        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2173        let path = dir.path().join("src/main.rs");
2174        let config = Config {
2175            project_root: Some(dir.path().to_path_buf()),
2176            ..Config::default()
2177        };
2178        let result = detect_type_checker(&path, LangId::Rust, &config);
2179        if resolve_tool("cargo", config.project_root.as_deref()).is_some() {
2180            let (cmd, args) = result.unwrap();
2181            assert_eq!(cmd, "cargo");
2182            assert!(args.contains(&"check".to_string()));
2183        } else {
2184            assert!(result.is_none());
2185        }
2186    }
2187
2188    #[test]
2189    fn detect_type_checker_go() {
2190        let dir = tempfile::tempdir().unwrap();
2191        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
2192        let path = dir.path().join("main.go");
2193        let config = Config {
2194            project_root: Some(dir.path().to_path_buf()),
2195            ..Config::default()
2196        };
2197        let result = detect_type_checker(&path, LangId::Go, &config);
2198        if resolve_tool("go", config.project_root.as_deref()).is_some() {
2199            let (cmd, _args) = result.unwrap();
2200            // Could be staticcheck or go vet depending on what's installed
2201            assert!(cmd == "go" || cmd == "staticcheck");
2202        } else {
2203            assert!(result.is_none());
2204        }
2205    }
2206    #[test]
2207    fn run_external_tool_capture_nonzero_not_error() {
2208        // `false` exits with code 1 — capture should still return Ok
2209        let result = run_external_tool_capture("false", &[], None, 5);
2210        assert!(result.is_ok(), "capture should not error on non-zero exit");
2211        assert_eq!(result.unwrap().exit_code, 1);
2212    }
2213
2214    #[test]
2215    fn run_external_tool_capture_not_found() {
2216        let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
2217        assert!(result.is_err());
2218        match result.unwrap_err() {
2219            FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
2220            other => panic!("expected NotFound, got: {:?}", other),
2221        }
2222    }
2223
2224    // GitHub issue #47: GUI-launched editors miss /opt/homebrew/bin etc. from
2225    // PATH. `try_well_known_path_lookup` should find the tool at well-known
2226    // install locations even when PATH wouldn't.
2227    #[cfg(unix)]
2228    #[test]
2229    fn well_known_search_paths_include_homebrew_cargo_go_and_local() {
2230        let home = std::ffi::OsString::from("/Users/test-home");
2231        let paths = well_known_search_paths("toolx", Some(&home));
2232        let strs: Vec<String> = paths
2233            .iter()
2234            .map(|p| p.to_string_lossy().into_owned())
2235            .collect();
2236        // Order matters: Homebrew prefixes come first so an installed-via-brew
2237        // tool wins over a HOME-rooted shim.
2238        assert_eq!(strs[0], "/opt/homebrew/bin/toolx");
2239        assert_eq!(strs[1], "/usr/local/bin/toolx");
2240        assert_eq!(strs[2], "/Users/test-home/.cargo/bin/toolx");
2241        assert_eq!(strs[3], "/Users/test-home/go/bin/toolx");
2242        assert_eq!(strs[4], "/Users/test-home/.local/bin/toolx");
2243        assert_eq!(strs.len(), 5);
2244    }
2245
2246    #[cfg(unix)]
2247    #[test]
2248    fn well_known_search_paths_skips_home_when_unset() {
2249        let paths = well_known_search_paths("toolx", None);
2250        assert_eq!(paths.len(), 2);
2251        assert!(paths[0].ends_with("opt/homebrew/bin/toolx"));
2252        assert!(paths[1].ends_with("usr/local/bin/toolx"));
2253    }
2254
2255    #[cfg(unix)]
2256    #[test]
2257    fn try_well_known_path_lookup_in_finds_executable_file() {
2258        use std::os::unix::fs::PermissionsExt;
2259        let dir = tempfile::tempdir().unwrap();
2260        let bin_dir = dir.path().join("bin");
2261        fs::create_dir_all(&bin_dir).unwrap();
2262        let tool_path = bin_dir.join("toolx");
2263        fs::write(&tool_path, "#!/bin/sh\necho test").unwrap();
2264        let mut perms = fs::metadata(&tool_path).unwrap().permissions();
2265        perms.set_mode(0o755);
2266        fs::set_permissions(&tool_path, perms).unwrap();
2267
2268        let candidates = vec![
2269            dir.path().join("missing/toolx"),
2270            tool_path.clone(),
2271            dir.path().join("alt/toolx"),
2272        ];
2273        let found = try_well_known_path_lookup_in(&candidates);
2274        assert_eq!(found, Some(tool_path));
2275    }
2276
2277    #[cfg(unix)]
2278    #[test]
2279    fn try_well_known_path_lookup_in_skips_non_executable_file() {
2280        let dir = tempfile::tempdir().unwrap();
2281        let bin_dir = dir.path().join("bin");
2282        fs::create_dir_all(&bin_dir).unwrap();
2283        // File exists but is not marked executable (default 0o644 on most umasks).
2284        let tool_path = bin_dir.join("toolx");
2285        fs::write(&tool_path, "not a real tool").unwrap();
2286
2287        let found = try_well_known_path_lookup_in(&std::slice::from_ref(&tool_path));
2288        assert!(found.is_none(), "non-executable file should be skipped");
2289    }
2290
2291    #[cfg(unix)]
2292    #[test]
2293    fn try_well_known_path_lookup_in_skips_directories_and_missing_paths() {
2294        let dir = tempfile::tempdir().unwrap();
2295        // A directory at the expected path should not count as a tool.
2296        let candidates = vec![dir.path().to_path_buf(), dir.path().join("does-not-exist")];
2297        assert!(try_well_known_path_lookup_in(&candidates).is_none());
2298    }
2299
2300    #[cfg(windows)]
2301    #[test]
2302    fn try_well_known_path_lookup_is_noop_on_windows() {
2303        // On Windows we deliberately skip POSIX well-known paths; only PATH
2304        // lookup applies. The public entry point should always return None.
2305        assert!(try_well_known_path_lookup("biome").is_none());
2306    }
2307
2308    // GitHub issue #47: wording must not claim "but not installed" — the tool
2309    // may be installed but missing from AFT's PATH (GUI-launched editor).
2310    #[test]
2311    fn configured_tool_hint_does_not_claim_not_installed() {
2312        let hint = configured_tool_hint("biome", "biome.json");
2313        assert!(
2314            hint.contains("was not found on PATH or in common install locations"),
2315            "hint should explain the PATH miss: got {:?}",
2316            hint
2317        );
2318        assert!(
2319            !hint.contains("but not installed"),
2320            "hint must not claim the tool isn't installed: got {:?}",
2321            hint
2322        );
2323    }
2324
2325    #[test]
2326    fn install_hint_for_go_mentions_path() {
2327        // Verify the Go-specific hint nudges users toward checking PATH
2328        // (Homebrew install location is the most common GUI-launch PATH miss).
2329        let hint = install_hint("go");
2330        assert!(
2331            hint.contains("PATH"),
2332            "go install hint should mention PATH: got {:?}",
2333            hint
2334        );
2335    }
2336}