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;
7use std::io::ErrorKind;
8use std::path::Path;
9use std::process::{Command, 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
25/// Errors from external tool execution.
26#[derive(Debug)]
27pub enum FormatError {
28    /// The tool binary was not found on PATH.
29    NotFound { tool: String },
30    /// The tool exceeded its timeout and was killed.
31    Timeout { tool: String, timeout_secs: u32 },
32    /// The tool exited with a non-zero status.
33    Failed { tool: String, stderr: String },
34    /// No formatter is configured for this language.
35    UnsupportedLanguage,
36}
37
38impl std::fmt::Display for FormatError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            FormatError::NotFound { tool } => write!(f, "formatter not found: {}", tool),
42            FormatError::Timeout { tool, timeout_secs } => {
43                write!(f, "formatter '{}' timed out after {}s", tool, timeout_secs)
44            }
45            FormatError::Failed { tool, stderr } => {
46                write!(f, "formatter '{}' failed: {}", tool, stderr)
47            }
48            FormatError::UnsupportedLanguage => write!(f, "unsupported language for formatting"),
49        }
50    }
51}
52
53/// Spawn a subprocess and wait for completion with timeout protection.
54///
55/// Polls `try_wait()` at 50ms intervals. On timeout, kills the child process
56/// and waits for it to exit. Returns `FormatError::NotFound` when the binary
57/// isn't on PATH.
58pub fn run_external_tool(
59    command: &str,
60    args: &[&str],
61    working_dir: Option<&Path>,
62    timeout_secs: u32,
63) -> Result<ExternalToolResult, FormatError> {
64    let mut cmd = Command::new(command);
65    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
66
67    if let Some(dir) = working_dir {
68        cmd.current_dir(dir);
69    }
70
71    let mut child = match cmd.spawn() {
72        Ok(c) => c,
73        Err(e) if e.kind() == ErrorKind::NotFound => {
74            return Err(FormatError::NotFound {
75                tool: command.to_string(),
76            });
77        }
78        Err(e) => {
79            return Err(FormatError::Failed {
80                tool: command.to_string(),
81                stderr: e.to_string(),
82            });
83        }
84    };
85
86    let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
87
88    loop {
89        match child.try_wait() {
90            Ok(Some(status)) => {
91                let stdout = child
92                    .stdout
93                    .take()
94                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
95                    .unwrap_or_default();
96                let stderr = child
97                    .stderr
98                    .take()
99                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
100                    .unwrap_or_default();
101
102                let exit_code = status.code().unwrap_or(-1);
103                if exit_code != 0 {
104                    return Err(FormatError::Failed {
105                        tool: command.to_string(),
106                        stderr,
107                    });
108                }
109
110                return Ok(ExternalToolResult {
111                    stdout,
112                    stderr,
113                    exit_code,
114                });
115            }
116            Ok(None) => {
117                // Still running
118                if Instant::now() >= deadline {
119                    // Kill the process and reap it
120                    let _ = child.kill();
121                    let _ = child.wait();
122                    return Err(FormatError::Timeout {
123                        tool: command.to_string(),
124                        timeout_secs,
125                    });
126                }
127                thread::sleep(Duration::from_millis(50));
128            }
129            Err(e) => {
130                return Err(FormatError::Failed {
131                    tool: command.to_string(),
132                    stderr: format!("try_wait error: {}", e),
133                });
134            }
135        }
136    }
137}
138
139/// TTL for tool availability cache entries.
140const TOOL_CACHE_TTL: Duration = Duration::from_secs(60);
141
142static TOOL_CACHE: std::sync::LazyLock<Mutex<HashMap<String, (bool, Instant)>>> =
143    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
144
145/// Check if a command exists by attempting to spawn it with `--version`.
146///
147/// First checks `<project_root>/node_modules/.bin/<command>` (for locally installed tools
148/// like biome, prettier), then falls back to PATH lookup.
149/// Results are cached for 60 seconds to avoid repeated subprocess spawning.
150fn tool_available(command: &str) -> bool {
151    if let Ok(cache) = TOOL_CACHE.lock() {
152        if let Some((available, checked_at)) = cache.get(command) {
153            if checked_at.elapsed() < TOOL_CACHE_TTL {
154                return *available;
155            }
156        }
157    }
158    let result = resolve_tool(command, None).is_some();
159    if let Ok(mut cache) = TOOL_CACHE.lock() {
160        cache.insert(command.to_string(), (result, Instant::now()));
161    }
162    result
163}
164
165/// Like `tool_available` but also checks node_modules/.bin relative to project_root.
166/// Returns the full path to the tool if found, otherwise None.
167fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
168    // 1. Check node_modules/.bin/<command> relative to project root
169    if let Some(root) = project_root {
170        let local_bin = root.join("node_modules").join(".bin").join(command);
171        if local_bin.exists() {
172            return Some(local_bin.to_string_lossy().to_string());
173        }
174    }
175
176    // 2. Fall back to PATH lookup
177    match Command::new(command)
178        .arg("--version")
179        .stdout(Stdio::null())
180        .stderr(Stdio::null())
181        .spawn()
182    {
183        Ok(mut child) => {
184            let _ = child.wait();
185            Some(command.to_string())
186        }
187        Err(_) => None,
188    }
189}
190
191/// Check if `ruff format` is available with a stable formatter.
192///
193/// Ruff's formatter became stable in v0.1.2. Versions before that output
194/// `NOT_YET_IMPLEMENTED_*` stubs instead of formatted code. We parse the
195/// version from `ruff --version` (format: "ruff X.Y.Z") and require >= 0.1.2.
196/// Falls back to false if ruff is not found or version cannot be parsed.
197fn ruff_format_available() -> bool {
198    let output = match Command::new("ruff")
199        .arg("--version")
200        .stdout(Stdio::piped())
201        .stderr(Stdio::null())
202        .output()
203    {
204        Ok(o) => o,
205        Err(_) => return false,
206    };
207
208    let version_str = String::from_utf8_lossy(&output.stdout);
209    // Parse "ruff X.Y.Z" or just "X.Y.Z"
210    let version_part = version_str
211        .trim()
212        .strip_prefix("ruff ")
213        .unwrap_or(version_str.trim());
214
215    let parts: Vec<&str> = version_part.split('.').collect();
216    if parts.len() < 3 {
217        return false;
218    }
219
220    let major: u32 = match parts[0].parse() {
221        Ok(v) => v,
222        Err(_) => return false,
223    };
224    let minor: u32 = match parts[1].parse() {
225        Ok(v) => v,
226        Err(_) => return false,
227    };
228    let patch: u32 = match parts[2].parse() {
229        Ok(v) => v,
230        Err(_) => return false,
231    };
232
233    // Require >= 0.1.2 where ruff format became stable
234    (major, minor, patch) >= (0, 1, 2)
235}
236
237/// Detect the appropriate formatter command and arguments for a file.
238///
239/// Priority per language:
240/// - TypeScript/JavaScript/TSX: `prettier --write <file>`
241/// - Python: `ruff format <file>` (fallback: `black <file>`)
242/// - Rust: `rustfmt <file>`
243/// - Go: `gofmt -w <file>`
244///
245/// Returns `None` if no formatter is available for the language.
246pub fn detect_formatter(
247    path: &Path,
248    lang: LangId,
249    config: &Config,
250) -> Option<(String, Vec<String>)> {
251    let file_str = path.to_string_lossy().to_string();
252
253    // 1. Per-language override from plugin config
254    let lang_key = match lang {
255        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
256        LangId::Python => "python",
257        LangId::Rust => "rust",
258        LangId::Go => "go",
259        LangId::Markdown => "markdown",
260    };
261    let project_root = config.project_root.as_deref();
262    if let Some(preferred) = config.formatter.get(lang_key) {
263        return resolve_explicit_formatter(preferred, &file_str, lang, project_root);
264    }
265
266    // 2. Project config file detection only — no config file means no formatting.
267    //    This avoids silently reformatting code in projects without formatter setup.
268
269    match lang {
270        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
271            // biome.json / biome.jsonc → biome (check node_modules/.bin first)
272            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
273                if let Some(biome_cmd) = resolve_tool("biome", project_root) {
274                    return Some((
275                        biome_cmd,
276                        vec!["format".to_string(), "--write".to_string(), file_str],
277                    ));
278                }
279            }
280            // .prettierrc / .prettierrc.* / prettier.config.* → prettier (check node_modules/.bin first)
281            if has_project_config(
282                project_root,
283                &[
284                    ".prettierrc",
285                    ".prettierrc.json",
286                    ".prettierrc.yml",
287                    ".prettierrc.yaml",
288                    ".prettierrc.js",
289                    ".prettierrc.cjs",
290                    ".prettierrc.mjs",
291                    ".prettierrc.toml",
292                    "prettier.config.js",
293                    "prettier.config.cjs",
294                    "prettier.config.mjs",
295                ],
296            ) {
297                if let Some(prettier_cmd) = resolve_tool("prettier", project_root) {
298                    return Some((prettier_cmd, vec!["--write".to_string(), file_str]));
299                }
300            }
301            // deno.json / deno.jsonc → deno fmt
302            if has_project_config(project_root, &["deno.json", "deno.jsonc"])
303                && tool_available("deno")
304            {
305                return Some(("deno".to_string(), vec!["fmt".to_string(), file_str]));
306            }
307            // No config file found → do not format
308            None
309        }
310        LangId::Python => {
311            // ruff.toml or pyproject.toml with ruff config → ruff
312            if (has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
313                || has_pyproject_tool(project_root, "ruff"))
314                && ruff_format_available()
315            {
316                return Some(("ruff".to_string(), vec!["format".to_string(), file_str]));
317            }
318            // pyproject.toml with black config → black
319            if has_pyproject_tool(project_root, "black") && tool_available("black") {
320                return Some(("black".to_string(), vec![file_str]));
321            }
322            // No config file found → do not format
323            None
324        }
325        LangId::Rust => {
326            // Cargo.toml implies standard Rust formatting
327            if has_project_config(project_root, &["Cargo.toml"]) && tool_available("rustfmt") {
328                Some(("rustfmt".to_string(), vec![file_str]))
329            } else {
330                None
331            }
332        }
333        LangId::Go => {
334            // go.mod implies a Go project
335            if has_project_config(project_root, &["go.mod"]) {
336                if tool_available("goimports") {
337                    Some(("goimports".to_string(), vec!["-w".to_string(), file_str]))
338                } else if tool_available("gofmt") {
339                    Some(("gofmt".to_string(), vec!["-w".to_string(), file_str]))
340                } else {
341                    None
342                }
343            } else {
344                None
345            }
346        }
347        LangId::Markdown => None,
348    }
349}
350
351/// Resolve an explicitly configured formatter name to a command + args.
352/// Uses resolve_tool() to find the binary in node_modules/.bin or PATH,
353/// so locally-installed tools (biome, prettier) are found even when not on PATH.
354fn resolve_explicit_formatter(
355    name: &str,
356    file_str: &str,
357    lang: LangId,
358    project_root: Option<&Path>,
359) -> Option<(String, Vec<String>)> {
360    let cmd = match name {
361        "none" | "off" | "false" => return None,
362        "biome" | "prettier" | "deno" | "ruff" | "black" | "rustfmt" | "goimports" | "gofmt" => {
363            // Resolve through node_modules/.bin first, then PATH
364            match resolve_tool(name, project_root) {
365                Some(resolved) => resolved,
366                None => {
367                    log::warn!(
368                        "[aft] format: configured formatter '{}' not found in node_modules/.bin or PATH",
369                        name
370                    );
371                    return None;
372                }
373            }
374        }
375        _ => {
376            log::debug!(
377                "[aft] format: unknown preferred_formatter '{}' for {:?}, falling back to auto",
378                name,
379                lang
380            );
381            return None;
382        }
383    };
384
385    let args = match name {
386        "biome" => vec![
387            "format".to_string(),
388            "--write".to_string(),
389            file_str.to_string(),
390        ],
391        "prettier" => vec!["--write".to_string(), file_str.to_string()],
392        "deno" => vec!["fmt".to_string(), file_str.to_string()],
393        "ruff" => vec!["format".to_string(), file_str.to_string()],
394        "black" => vec![file_str.to_string()],
395        "rustfmt" => vec![file_str.to_string()],
396        "goimports" => vec!["-w".to_string(), file_str.to_string()],
397        "gofmt" => vec!["-w".to_string(), file_str.to_string()],
398        _ => unreachable!(), // Already handled above
399    };
400
401    Some((cmd, args))
402}
403
404/// Check if any of the given config file names exist in the project root.
405fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
406    let root = match project_root {
407        Some(r) => r,
408        None => return false,
409    };
410    filenames.iter().any(|f| root.join(f).exists())
411}
412
413/// Check if pyproject.toml exists and contains a `[tool.<name>]` section.
414fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
415    let root = match project_root {
416        Some(r) => r,
417        None => return false,
418    };
419    let pyproject = root.join("pyproject.toml");
420    if !pyproject.exists() {
421        return false;
422    }
423    match std::fs::read_to_string(&pyproject) {
424        Ok(content) => {
425            let pattern = format!("[tool.{}]", tool_name);
426            content.contains(&pattern)
427        }
428        Err(_) => false,
429    }
430}
431
432/// Auto-format a file using the detected formatter for its language.
433///
434/// Returns `(formatted, skip_reason)`:
435/// - `(true, None)` — file was successfully formatted
436/// - `(false, Some(reason))` — formatting was skipped, reason explains why
437///
438/// Skip reasons: `"unsupported_language"`, `"not_found"`, `"timeout"`, `"error"`
439pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
440    // Check if formatting is disabled via plugin config
441    if !config.format_on_edit {
442        return (false, Some("disabled".to_string()));
443    }
444
445    let lang = match detect_language(path) {
446        Some(l) => l,
447        None => {
448            log::debug!(
449                "[aft] format: {} (skipped: unsupported_language)",
450                path.display()
451            );
452            return (false, Some("unsupported_language".to_string()));
453        }
454    };
455
456    let (cmd, args) = match detect_formatter(path, lang, config) {
457        Some(pair) => pair,
458        None => {
459            log::warn!("format: {} (skipped: not_found)", path.display());
460            return (false, Some("not_found".to_string()));
461        }
462    };
463
464    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
465
466    match run_external_tool(&cmd, &arg_refs, None, config.formatter_timeout_secs) {
467        Ok(_) => {
468            log::info!("format: {} ({})", path.display(), cmd);
469            (true, None)
470        }
471        Err(FormatError::Timeout { .. }) => {
472            log::warn!("format: {} (skipped: timeout)", path.display());
473            (false, Some("timeout".to_string()))
474        }
475        Err(FormatError::NotFound { .. }) => {
476            log::warn!("format: {} (skipped: not_found)", path.display());
477            (false, Some("not_found".to_string()))
478        }
479        Err(FormatError::Failed { stderr, .. }) => {
480            log::debug!(
481                "[aft] format: {} (skipped: error: {})",
482                path.display(),
483                stderr.lines().next().unwrap_or("unknown")
484            );
485            (false, Some("error".to_string()))
486        }
487        Err(FormatError::UnsupportedLanguage) => {
488            log::debug!(
489                "[aft] format: {} (skipped: unsupported_language)",
490                path.display()
491            );
492            (false, Some("unsupported_language".to_string()))
493        }
494    }
495}
496
497/// Spawn a subprocess and capture output regardless of exit code.
498///
499/// Unlike `run_external_tool`, this does NOT treat non-zero exit as an error —
500/// type checkers return non-zero when they find issues, which is expected.
501/// Returns `FormatError::NotFound` when the binary isn't on PATH, and
502/// `FormatError::Timeout` if the deadline is exceeded.
503pub fn run_external_tool_capture(
504    command: &str,
505    args: &[&str],
506    working_dir: Option<&Path>,
507    timeout_secs: u32,
508) -> Result<ExternalToolResult, FormatError> {
509    let mut cmd = Command::new(command);
510    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
511
512    if let Some(dir) = working_dir {
513        cmd.current_dir(dir);
514    }
515
516    let mut child = match cmd.spawn() {
517        Ok(c) => c,
518        Err(e) if e.kind() == ErrorKind::NotFound => {
519            return Err(FormatError::NotFound {
520                tool: command.to_string(),
521            });
522        }
523        Err(e) => {
524            return Err(FormatError::Failed {
525                tool: command.to_string(),
526                stderr: e.to_string(),
527            });
528        }
529    };
530
531    let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
532
533    loop {
534        match child.try_wait() {
535            Ok(Some(status)) => {
536                let stdout = child
537                    .stdout
538                    .take()
539                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
540                    .unwrap_or_default();
541                let stderr = child
542                    .stderr
543                    .take()
544                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
545                    .unwrap_or_default();
546
547                return Ok(ExternalToolResult {
548                    stdout,
549                    stderr,
550                    exit_code: status.code().unwrap_or(-1),
551                });
552            }
553            Ok(None) => {
554                if Instant::now() >= deadline {
555                    let _ = child.kill();
556                    let _ = child.wait();
557                    return Err(FormatError::Timeout {
558                        tool: command.to_string(),
559                        timeout_secs,
560                    });
561                }
562                thread::sleep(Duration::from_millis(50));
563            }
564            Err(e) => {
565                return Err(FormatError::Failed {
566                    tool: command.to_string(),
567                    stderr: format!("try_wait error: {}", e),
568                });
569            }
570        }
571    }
572}
573
574// ============================================================================
575// Type-checker validation (R017)
576// ============================================================================
577
578/// A structured error from a type checker.
579#[derive(Debug, Clone, serde::Serialize)]
580pub struct ValidationError {
581    pub line: u32,
582    pub column: u32,
583    pub message: String,
584    pub severity: String,
585}
586
587/// Detect the appropriate type checker command and arguments for a file.
588///
589/// Returns `(command, args)` for the type checker. The `--noEmit` / equivalent
590/// flags ensure no output files are produced.
591///
592/// Supported:
593/// - TypeScript/JavaScript/TSX → `npx tsc --noEmit` (fallback: `tsc --noEmit`)
594/// - Python → `pyright`
595/// - Rust → `cargo check`
596/// - Go → `go vet`
597pub fn detect_type_checker(
598    path: &Path,
599    lang: LangId,
600    config: &Config,
601) -> Option<(String, Vec<String>)> {
602    let file_str = path.to_string_lossy().to_string();
603    let project_root = config.project_root.as_deref();
604
605    // Per-language override from plugin config
606    let lang_key = match lang {
607        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
608        LangId::Python => "python",
609        LangId::Rust => "rust",
610        LangId::Go => "go",
611        LangId::Markdown => "markdown",
612    };
613    if let Some(preferred) = config.checker.get(lang_key) {
614        return resolve_explicit_checker(preferred, &file_str, lang, project_root);
615    }
616
617    match lang {
618        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
619            // biome.json → biome check (lint + type errors, check node_modules/.bin first)
620            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
621                if let Some(biome_cmd) = resolve_tool("biome", project_root) {
622                    return Some((biome_cmd, vec!["check".to_string(), file_str]));
623                }
624            }
625            // tsconfig.json → tsc (check node_modules/.bin first)
626            if has_project_config(project_root, &["tsconfig.json"]) {
627                if let Some(tsc_cmd) = resolve_tool("tsc", project_root) {
628                    return Some((
629                        tsc_cmd,
630                        vec![
631                            "--noEmit".to_string(),
632                            "--pretty".to_string(),
633                            "false".to_string(),
634                        ],
635                    ));
636                } else if tool_available("npx") {
637                    return Some((
638                        "npx".to_string(),
639                        vec![
640                            "tsc".to_string(),
641                            "--noEmit".to_string(),
642                            "--pretty".to_string(),
643                            "false".to_string(),
644                        ],
645                    ));
646                }
647            }
648            None
649        }
650        LangId::Python => {
651            // pyrightconfig.json or pyproject.toml with pyright → pyright
652            if has_project_config(project_root, &["pyrightconfig.json"])
653                || has_pyproject_tool(project_root, "pyright")
654            {
655                if let Some(pyright_cmd) = resolve_tool("pyright", project_root) {
656                    return Some((pyright_cmd, vec!["--outputjson".to_string(), file_str]));
657                }
658            }
659            // ruff.toml or pyproject.toml with ruff → ruff check
660            if (has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
661                || has_pyproject_tool(project_root, "ruff"))
662                && ruff_format_available()
663            {
664                return Some((
665                    "ruff".to_string(),
666                    vec![
667                        "check".to_string(),
668                        "--output-format=json".to_string(),
669                        file_str,
670                    ],
671                ));
672            }
673            None
674        }
675        LangId::Rust => {
676            // Cargo.toml implies cargo check
677            if has_project_config(project_root, &["Cargo.toml"]) && tool_available("cargo") {
678                Some((
679                    "cargo".to_string(),
680                    vec!["check".to_string(), "--message-format=json".to_string()],
681                ))
682            } else {
683                None
684            }
685        }
686        LangId::Go => {
687            // go.mod implies Go project
688            if has_project_config(project_root, &["go.mod"]) {
689                if tool_available("staticcheck") {
690                    Some(("staticcheck".to_string(), vec![file_str]))
691                } else if tool_available("go") {
692                    Some(("go".to_string(), vec!["vet".to_string(), file_str]))
693                } else {
694                    None
695                }
696            } else {
697                None
698            }
699        }
700        LangId::Markdown => None,
701    }
702}
703
704/// Resolve an explicitly configured checker name to a command + args.
705/// Uses resolve_tool() to find the binary in node_modules/.bin or PATH,
706/// so locally-installed tools (biome, tsc, pyright) are found even when not on PATH.
707fn resolve_explicit_checker(
708    name: &str,
709    file_str: &str,
710    _lang: LangId,
711    project_root: Option<&Path>,
712) -> Option<(String, Vec<String>)> {
713    match name {
714        "none" | "off" | "false" => return None,
715        _ => {}
716    }
717
718    // tsc is special — always runs via npx
719    if name == "tsc" {
720        return Some((
721            "npx".to_string(),
722            vec![
723                "tsc".to_string(),
724                "--noEmit".to_string(),
725                "--pretty".to_string(),
726                "false".to_string(),
727            ],
728        ));
729    }
730    // cargo and go are system tools, not in node_modules
731    if name == "cargo" {
732        return Some((
733            "cargo".to_string(),
734            vec!["check".to_string(), "--message-format=json".to_string()],
735        ));
736    }
737    if name == "go" {
738        return Some((
739            "go".to_string(),
740            vec!["vet".to_string(), file_str.to_string()],
741        ));
742    }
743
744    // For node-ecosystem tools, resolve through node_modules/.bin first
745    let known_tools = ["biome", "pyright", "ruff", "staticcheck"];
746    if known_tools.contains(&name) {
747        let cmd = match resolve_tool(name, project_root) {
748            Some(resolved) => resolved,
749            None => {
750                log::warn!(
751                    "[aft] validate: configured checker '{}' not found in node_modules/.bin or PATH",
752                    name
753                );
754                return None;
755            }
756        };
757
758        let args = match name {
759            "biome" => vec!["check".to_string(), file_str.to_string()],
760            "pyright" => vec!["--outputjson".to_string(), file_str.to_string()],
761            "ruff" => vec![
762                "check".to_string(),
763                "--output-format=json".to_string(),
764                file_str.to_string(),
765            ],
766            "staticcheck" => vec![file_str.to_string()],
767            _ => unreachable!(),
768        };
769
770        return Some((cmd, args));
771    }
772
773    log::debug!(
774        "[aft] validate: unknown preferred_checker '{}', falling back to auto",
775        name
776    );
777    None
778}
779
780/// Parse type checker output into structured validation errors.
781///
782/// Handles output formats from tsc, pyright (JSON), cargo check (JSON), and go vet.
783/// Filters to errors related to the edited file where feasible.
784pub fn parse_checker_output(
785    stdout: &str,
786    stderr: &str,
787    file: &Path,
788    checker: &str,
789) -> Vec<ValidationError> {
790    match checker {
791        "npx" | "tsc" => parse_tsc_output(stdout, stderr, file),
792        "pyright" => parse_pyright_output(stdout, file),
793        "cargo" => parse_cargo_output(stdout, stderr, file),
794        "go" => parse_go_vet_output(stderr, file),
795        _ => Vec::new(),
796    }
797}
798
799/// Parse tsc output lines like: `path(line,col): error TSxxxx: message`
800fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
801    let mut errors = Vec::new();
802    let file_str = file.to_string_lossy();
803    // tsc writes diagnostics to stdout (with --pretty false)
804    let combined = format!("{}{}", stdout, stderr);
805    for line in combined.lines() {
806        // Format: path(line,col): severity TSxxxx: message
807        // or: path(line,col): severity: message
808        if let Some((loc, rest)) = line.split_once("): ") {
809            // Check if this error is for our file (compare filename part)
810            let file_part = loc.split('(').next().unwrap_or("");
811            if !file_str.ends_with(file_part)
812                && !file_part.ends_with(&*file_str)
813                && file_part != &*file_str
814            {
815                continue;
816            }
817
818            // Parse (line,col) from the location part
819            let coords = loc.split('(').last().unwrap_or("");
820            let parts: Vec<&str> = coords.split(',').collect();
821            let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
822            let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
823
824            // Parse severity and message
825            let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
826                ("error".to_string(), msg.to_string())
827            } else if let Some(msg) = rest.strip_prefix("warning ") {
828                ("warning".to_string(), msg.to_string())
829            } else {
830                ("error".to_string(), rest.to_string())
831            };
832
833            errors.push(ValidationError {
834                line: line_num,
835                column: col_num,
836                message,
837                severity,
838            });
839        }
840    }
841    errors
842}
843
844/// Parse pyright JSON output.
845fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
846    let mut errors = Vec::new();
847    let file_str = file.to_string_lossy();
848
849    // pyright --outputjson emits JSON with generalDiagnostics array
850    if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
851        if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
852            for diag in diags {
853                // Filter to our file
854                let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
855                if !diag_file.is_empty()
856                    && !file_str.ends_with(diag_file)
857                    && !diag_file.ends_with(&*file_str)
858                    && diag_file != &*file_str
859                {
860                    continue;
861                }
862
863                let line_num = diag
864                    .get("range")
865                    .and_then(|r| r.get("start"))
866                    .and_then(|s| s.get("line"))
867                    .and_then(|l| l.as_u64())
868                    .unwrap_or(0) as u32;
869                let col_num = diag
870                    .get("range")
871                    .and_then(|r| r.get("start"))
872                    .and_then(|s| s.get("character"))
873                    .and_then(|c| c.as_u64())
874                    .unwrap_or(0) as u32;
875                let message = diag
876                    .get("message")
877                    .and_then(|m| m.as_str())
878                    .unwrap_or("unknown error")
879                    .to_string();
880                let severity = diag
881                    .get("severity")
882                    .and_then(|s| s.as_str())
883                    .unwrap_or("error")
884                    .to_lowercase();
885
886                errors.push(ValidationError {
887                    line: line_num + 1, // pyright uses 0-indexed lines
888                    column: col_num,
889                    message,
890                    severity,
891                });
892            }
893        }
894    }
895    errors
896}
897
898/// Parse cargo check JSON output, filtering to errors in the target file.
899fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
900    let mut errors = Vec::new();
901    let file_str = file.to_string_lossy();
902
903    for line in stdout.lines() {
904        if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
905            if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
906                continue;
907            }
908            let message_obj = match msg.get("message") {
909                Some(m) => m,
910                None => continue,
911            };
912
913            let level = message_obj
914                .get("level")
915                .and_then(|l| l.as_str())
916                .unwrap_or("error");
917
918            // Only include errors and warnings, skip notes/help
919            if level != "error" && level != "warning" {
920                continue;
921            }
922
923            let text = message_obj
924                .get("message")
925                .and_then(|m| m.as_str())
926                .unwrap_or("unknown error")
927                .to_string();
928
929            // Find the primary span for our file
930            if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
931                for span in spans {
932                    let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
933                    let is_primary = span
934                        .get("is_primary")
935                        .and_then(|p| p.as_bool())
936                        .unwrap_or(false);
937
938                    if !is_primary {
939                        continue;
940                    }
941
942                    // Filter to our file
943                    if !file_str.ends_with(span_file)
944                        && !span_file.ends_with(&*file_str)
945                        && span_file != &*file_str
946                    {
947                        continue;
948                    }
949
950                    let line_num =
951                        span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
952                    let col_num = span
953                        .get("column_start")
954                        .and_then(|c| c.as_u64())
955                        .unwrap_or(0) as u32;
956
957                    errors.push(ValidationError {
958                        line: line_num,
959                        column: col_num,
960                        message: text.clone(),
961                        severity: level.to_string(),
962                    });
963                }
964            }
965        }
966    }
967    errors
968}
969
970/// Parse go vet output lines like: `path:line:col: message`
971fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
972    let mut errors = Vec::new();
973    let file_str = file.to_string_lossy();
974
975    for line in stderr.lines() {
976        // Format: path:line:col: message  OR  path:line: message
977        let parts: Vec<&str> = line.splitn(4, ':').collect();
978        if parts.len() < 3 {
979            continue;
980        }
981
982        let err_file = parts[0].trim();
983        if !file_str.ends_with(err_file)
984            && !err_file.ends_with(&*file_str)
985            && err_file != &*file_str
986        {
987            continue;
988        }
989
990        let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
991        let (col_num, message) = if parts.len() >= 4 {
992            if let Ok(col) = parts[2].trim().parse::<u32>() {
993                (col, parts[3].trim().to_string())
994            } else {
995                // parts[2] is part of the message, not a column
996                (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
997            }
998        } else {
999            (0, parts[2].trim().to_string())
1000        };
1001
1002        errors.push(ValidationError {
1003            line: line_num,
1004            column: col_num,
1005            message,
1006            severity: "error".to_string(),
1007        });
1008    }
1009    errors
1010}
1011
1012/// Run the project's type checker and return structured validation errors.
1013///
1014/// Returns `(errors, skip_reason)`:
1015/// - `(errors, None)` — checker ran, errors may be empty (= valid code)
1016/// - `([], Some(reason))` — checker was skipped
1017///
1018/// Skip reasons: `"unsupported_language"`, `"not_found"`, `"timeout"`, `"error"`
1019pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
1020    let lang = match detect_language(path) {
1021        Some(l) => l,
1022        None => {
1023            log::debug!(
1024                "[aft] validate: {} (skipped: unsupported_language)",
1025                path.display()
1026            );
1027            return (Vec::new(), Some("unsupported_language".to_string()));
1028        }
1029    };
1030
1031    let (cmd, args) = match detect_type_checker(path, lang, config) {
1032        Some(pair) => pair,
1033        None => {
1034            log::warn!("validate: {} (skipped: not_found)", path.display());
1035            return (Vec::new(), Some("not_found".to_string()));
1036        }
1037    };
1038
1039    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1040
1041    // Type checkers may need to run from the project root
1042    let working_dir = path.parent();
1043
1044    match run_external_tool_capture(
1045        &cmd,
1046        &arg_refs,
1047        working_dir,
1048        config.type_checker_timeout_secs,
1049    ) {
1050        Ok(result) => {
1051            let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
1052            log::debug!(
1053                "[aft] validate: {} ({}, {} errors)",
1054                path.display(),
1055                cmd,
1056                errors.len()
1057            );
1058            (errors, None)
1059        }
1060        Err(FormatError::Timeout { .. }) => {
1061            log::error!("validate: {} (skipped: timeout)", path.display());
1062            (Vec::new(), Some("timeout".to_string()))
1063        }
1064        Err(FormatError::NotFound { .. }) => {
1065            log::warn!("validate: {} (skipped: not_found)", path.display());
1066            (Vec::new(), Some("not_found".to_string()))
1067        }
1068        Err(FormatError::Failed { stderr, .. }) => {
1069            log::debug!(
1070                "[aft] validate: {} (skipped: error: {})",
1071                path.display(),
1072                stderr.lines().next().unwrap_or("unknown")
1073            );
1074            (Vec::new(), Some("error".to_string()))
1075        }
1076        Err(FormatError::UnsupportedLanguage) => {
1077            log::debug!(
1078                "[aft] validate: {} (skipped: unsupported_language)",
1079                path.display()
1080            );
1081            (Vec::new(), Some("unsupported_language".to_string()))
1082        }
1083    }
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088    use super::*;
1089    use std::fs;
1090    use std::io::Write;
1091
1092    #[test]
1093    fn run_external_tool_not_found() {
1094        let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1095        assert!(result.is_err());
1096        match result.unwrap_err() {
1097            FormatError::NotFound { tool } => {
1098                assert_eq!(tool, "__nonexistent_tool_xyz__");
1099            }
1100            other => panic!("expected NotFound, got: {:?}", other),
1101        }
1102    }
1103
1104    #[test]
1105    fn run_external_tool_timeout_kills_subprocess() {
1106        // Use `sleep 60` as a long-running process, timeout after 1 second
1107        let result = run_external_tool("sleep", &["60"], None, 1);
1108        assert!(result.is_err());
1109        match result.unwrap_err() {
1110            FormatError::Timeout { tool, timeout_secs } => {
1111                assert_eq!(tool, "sleep");
1112                assert_eq!(timeout_secs, 1);
1113            }
1114            other => panic!("expected Timeout, got: {:?}", other),
1115        }
1116    }
1117
1118    #[test]
1119    fn run_external_tool_success() {
1120        let result = run_external_tool("echo", &["hello"], None, 5);
1121        assert!(result.is_ok());
1122        let res = result.unwrap();
1123        assert_eq!(res.exit_code, 0);
1124        assert!(res.stdout.contains("hello"));
1125    }
1126
1127    #[test]
1128    fn run_external_tool_nonzero_exit() {
1129        // `false` always exits with code 1
1130        let result = run_external_tool("false", &[], None, 5);
1131        assert!(result.is_err());
1132        match result.unwrap_err() {
1133            FormatError::Failed { tool, .. } => {
1134                assert_eq!(tool, "false");
1135            }
1136            other => panic!("expected Failed, got: {:?}", other),
1137        }
1138    }
1139
1140    #[test]
1141    fn auto_format_unsupported_language() {
1142        let dir = tempfile::tempdir().unwrap();
1143        let path = dir.path().join("file.txt");
1144        fs::write(&path, "hello").unwrap();
1145
1146        let config = Config::default();
1147        let (formatted, reason) = auto_format(&path, &config);
1148        assert!(!formatted);
1149        assert_eq!(reason.as_deref(), Some("unsupported_language"));
1150    }
1151
1152    #[test]
1153    fn detect_formatter_rust_when_rustfmt_available() {
1154        let dir = tempfile::tempdir().unwrap();
1155        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1156        let path = dir.path().join("test.rs");
1157        let config = Config {
1158            project_root: Some(dir.path().to_path_buf()),
1159            ..Config::default()
1160        };
1161        let result = detect_formatter(&path, LangId::Rust, &config);
1162        if tool_available("rustfmt") {
1163            let (cmd, args) = result.unwrap();
1164            assert_eq!(cmd, "rustfmt");
1165            assert!(args.iter().any(|a| a.ends_with("test.rs")));
1166        } else {
1167            assert!(result.is_none());
1168        }
1169    }
1170
1171    #[test]
1172    fn detect_formatter_go_mapping() {
1173        let dir = tempfile::tempdir().unwrap();
1174        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1175        let path = dir.path().join("main.go");
1176        let config = Config {
1177            project_root: Some(dir.path().to_path_buf()),
1178            ..Config::default()
1179        };
1180        let result = detect_formatter(&path, LangId::Go, &config);
1181        if tool_available("goimports") {
1182            let (cmd, args) = result.unwrap();
1183            assert_eq!(cmd, "goimports");
1184            assert!(args.contains(&"-w".to_string()));
1185        } else if tool_available("gofmt") {
1186            let (cmd, args) = result.unwrap();
1187            assert_eq!(cmd, "gofmt");
1188            assert!(args.contains(&"-w".to_string()));
1189        } else {
1190            assert!(result.is_none());
1191        }
1192    }
1193
1194    #[test]
1195    fn detect_formatter_python_mapping() {
1196        let dir = tempfile::tempdir().unwrap();
1197        fs::write(dir.path().join("ruff.toml"), "").unwrap();
1198        let path = dir.path().join("main.py");
1199        let config = Config {
1200            project_root: Some(dir.path().to_path_buf()),
1201            ..Config::default()
1202        };
1203        let result = detect_formatter(&path, LangId::Python, &config);
1204        if ruff_format_available() {
1205            let (cmd, args) = result.unwrap();
1206            assert_eq!(cmd, "ruff");
1207            assert!(args.contains(&"format".to_string()));
1208        } else {
1209            assert!(result.is_none());
1210        }
1211    }
1212
1213    #[test]
1214    fn detect_formatter_no_config_returns_none() {
1215        let path = Path::new("test.ts");
1216        let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1217        assert!(
1218            result.is_none(),
1219            "expected no formatter without project config"
1220        );
1221    }
1222
1223    #[test]
1224    fn detect_formatter_explicit_override() {
1225        // Create a temp dir with a fake node_modules/.bin/biome so resolve_tool finds it
1226        let dir = tempfile::tempdir().unwrap();
1227        let bin_dir = dir.path().join("node_modules").join(".bin");
1228        fs::create_dir_all(&bin_dir).unwrap();
1229        #[cfg(unix)]
1230        {
1231            use std::os::unix::fs::PermissionsExt;
1232            let fake = bin_dir.join("biome");
1233            fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
1234            fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
1235        }
1236        #[cfg(not(unix))]
1237        {
1238            fs::write(bin_dir.join("biome.cmd"), "@echo 1.0.0").unwrap();
1239        }
1240
1241        let path = Path::new("test.ts");
1242        let mut config = Config::default();
1243        config.project_root = Some(dir.path().to_path_buf());
1244        config
1245            .formatter
1246            .insert("typescript".to_string(), "biome".to_string());
1247        let result = detect_formatter(path, LangId::TypeScript, &config);
1248        let (cmd, args) = result.unwrap();
1249        assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
1250        assert!(args.contains(&"format".to_string()));
1251        assert!(args.contains(&"--write".to_string()));
1252    }
1253
1254    #[test]
1255    fn auto_format_happy_path_rustfmt() {
1256        if !tool_available("rustfmt") {
1257            log::warn!("skipping: rustfmt not available");
1258            return;
1259        }
1260
1261        let dir = tempfile::tempdir().unwrap();
1262        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1263        let path = dir.path().join("test.rs");
1264
1265        let mut f = fs::File::create(&path).unwrap();
1266        writeln!(f, "fn    main()   {{  println!(\"hello\");  }}").unwrap();
1267        drop(f);
1268
1269        let config = Config {
1270            project_root: Some(dir.path().to_path_buf()),
1271            ..Config::default()
1272        };
1273        let (formatted, reason) = auto_format(&path, &config);
1274        assert!(formatted, "expected formatting to succeed");
1275        assert!(reason.is_none());
1276
1277        let content = fs::read_to_string(&path).unwrap();
1278        assert!(
1279            !content.contains("fn    main"),
1280            "expected rustfmt to fix spacing"
1281        );
1282    }
1283
1284    #[test]
1285    fn parse_tsc_output_basic() {
1286        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";
1287        let file = Path::new("src/app.ts");
1288        let errors = parse_tsc_output(stdout, "", file);
1289        assert_eq!(errors.len(), 2);
1290        assert_eq!(errors[0].line, 10);
1291        assert_eq!(errors[0].column, 5);
1292        assert_eq!(errors[0].severity, "error");
1293        assert!(errors[0].message.contains("TS2322"));
1294        assert_eq!(errors[1].line, 20);
1295    }
1296
1297    #[test]
1298    fn parse_tsc_output_filters_other_files() {
1299        let stdout =
1300            "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
1301        let file = Path::new("src/app.ts");
1302        let errors = parse_tsc_output(stdout, "", file);
1303        assert_eq!(errors.len(), 1);
1304        assert_eq!(errors[0].line, 5);
1305    }
1306
1307    #[test]
1308    fn parse_cargo_output_basic() {
1309        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}]}}"#;
1310        let file = Path::new("src/main.rs");
1311        let errors = parse_cargo_output(json_line, "", file);
1312        assert_eq!(errors.len(), 1);
1313        assert_eq!(errors[0].line, 10);
1314        assert_eq!(errors[0].column, 5);
1315        assert_eq!(errors[0].severity, "error");
1316        assert!(errors[0].message.contains("mismatched types"));
1317    }
1318
1319    #[test]
1320    fn parse_cargo_output_skips_notes() {
1321        // Notes and help messages should be filtered out
1322        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}]}}"#;
1323        let file = Path::new("src/main.rs");
1324        let errors = parse_cargo_output(json_line, "", file);
1325        assert_eq!(errors.len(), 0);
1326    }
1327
1328    #[test]
1329    fn parse_cargo_output_filters_other_files() {
1330        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}]}}"#;
1331        let file = Path::new("src/main.rs");
1332        let errors = parse_cargo_output(json_line, "", file);
1333        assert_eq!(errors.len(), 0);
1334    }
1335
1336    #[test]
1337    fn parse_go_vet_output_basic() {
1338        let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
1339        let file = Path::new("main.go");
1340        let errors = parse_go_vet_output(stderr, file);
1341        assert_eq!(errors.len(), 2);
1342        assert_eq!(errors[0].line, 10);
1343        assert_eq!(errors[0].column, 5);
1344        assert!(errors[0].message.contains("unreachable code"));
1345        assert_eq!(errors[1].line, 20);
1346        assert_eq!(errors[1].column, 0);
1347    }
1348
1349    #[test]
1350    fn parse_pyright_output_basic() {
1351        let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
1352        let file = Path::new("test.py");
1353        let errors = parse_pyright_output(stdout, file);
1354        assert_eq!(errors.len(), 1);
1355        assert_eq!(errors[0].line, 5); // 0-indexed → 1-indexed
1356        assert_eq!(errors[0].column, 10);
1357        assert_eq!(errors[0].severity, "error");
1358        assert!(errors[0].message.contains("Type error here"));
1359    }
1360
1361    #[test]
1362    fn validate_full_unsupported_language() {
1363        let dir = tempfile::tempdir().unwrap();
1364        let path = dir.path().join("file.txt");
1365        fs::write(&path, "hello").unwrap();
1366
1367        let config = Config::default();
1368        let (errors, reason) = validate_full(&path, &config);
1369        assert!(errors.is_empty());
1370        assert_eq!(reason.as_deref(), Some("unsupported_language"));
1371    }
1372
1373    #[test]
1374    fn detect_type_checker_rust() {
1375        let dir = tempfile::tempdir().unwrap();
1376        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1377        let path = dir.path().join("src/main.rs");
1378        let config = Config {
1379            project_root: Some(dir.path().to_path_buf()),
1380            ..Config::default()
1381        };
1382        let result = detect_type_checker(&path, LangId::Rust, &config);
1383        if tool_available("cargo") {
1384            let (cmd, args) = result.unwrap();
1385            assert_eq!(cmd, "cargo");
1386            assert!(args.contains(&"check".to_string()));
1387        } else {
1388            assert!(result.is_none());
1389        }
1390    }
1391
1392    #[test]
1393    fn detect_type_checker_go() {
1394        let dir = tempfile::tempdir().unwrap();
1395        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1396        let path = dir.path().join("main.go");
1397        let config = Config {
1398            project_root: Some(dir.path().to_path_buf()),
1399            ..Config::default()
1400        };
1401        let result = detect_type_checker(&path, LangId::Go, &config);
1402        if tool_available("go") {
1403            let (cmd, _args) = result.unwrap();
1404            // Could be staticcheck or go vet depending on what's installed
1405            assert!(cmd == "go" || cmd == "staticcheck");
1406        } else {
1407            assert!(result.is_none());
1408        }
1409    }
1410    #[test]
1411    fn run_external_tool_capture_nonzero_not_error() {
1412        // `false` exits with code 1 — capture should still return Ok
1413        let result = run_external_tool_capture("false", &[], None, 5);
1414        assert!(result.is_ok(), "capture should not error on non-zero exit");
1415        assert_eq!(result.unwrap().exit_code, 1);
1416    }
1417
1418    #[test]
1419    fn run_external_tool_capture_not_found() {
1420        let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
1421        assert!(result.is_err());
1422        match result.unwrap_err() {
1423            FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
1424            other => panic!("expected NotFound, got: {:?}", other),
1425        }
1426    }
1427}