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;
8use std::path::{Path, PathBuf};
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
38/// A configured formatter/checker that cannot be resolved for configure warnings.
39#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
40pub struct MissingTool {
41    pub kind: String,
42    pub language: String,
43    pub tool: String,
44    pub hint: String,
45}
46
47#[derive(Debug, Clone)]
48struct ToolCandidate {
49    tool: String,
50    source: String,
51    args: Vec<String>,
52    required: bool,
53}
54
55#[derive(Debug, Clone)]
56enum ToolDetection {
57    Found(String, Vec<String>),
58    NotConfigured,
59    NotInstalled { tool: String },
60}
61
62impl std::fmt::Display for FormatError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            FormatError::NotFound { tool } => write!(f, "formatter not found: {}", tool),
66            FormatError::Timeout { tool, timeout_secs } => {
67                write!(f, "formatter '{}' timed out after {}s", tool, timeout_secs)
68            }
69            FormatError::Failed { tool, stderr } => {
70                write!(f, "formatter '{}' failed: {}", tool, stderr)
71            }
72            FormatError::UnsupportedLanguage => write!(f, "unsupported language for formatting"),
73        }
74    }
75}
76
77/// Spawn a subprocess and wait for completion with timeout protection.
78///
79/// Polls `try_wait()` at 50ms intervals. On timeout, kills the child process
80/// and waits for it to exit. Returns `FormatError::NotFound` when the binary
81/// isn't on PATH.
82pub fn run_external_tool(
83    command: &str,
84    args: &[&str],
85    working_dir: Option<&Path>,
86    timeout_secs: u32,
87) -> Result<ExternalToolResult, FormatError> {
88    let mut cmd = Command::new(command);
89    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
90
91    if let Some(dir) = working_dir {
92        cmd.current_dir(dir);
93    }
94
95    let mut child = match cmd.spawn() {
96        Ok(c) => c,
97        Err(e) if e.kind() == ErrorKind::NotFound => {
98            return Err(FormatError::NotFound {
99                tool: command.to_string(),
100            });
101        }
102        Err(e) => {
103            return Err(FormatError::Failed {
104                tool: command.to_string(),
105                stderr: e.to_string(),
106            });
107        }
108    };
109
110    let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
111
112    loop {
113        match child.try_wait() {
114            Ok(Some(status)) => {
115                let stdout = child
116                    .stdout
117                    .take()
118                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
119                    .unwrap_or_default();
120                let stderr = child
121                    .stderr
122                    .take()
123                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
124                    .unwrap_or_default();
125
126                let exit_code = status.code().unwrap_or(-1);
127                if exit_code != 0 {
128                    return Err(FormatError::Failed {
129                        tool: command.to_string(),
130                        stderr,
131                    });
132                }
133
134                return Ok(ExternalToolResult {
135                    stdout,
136                    stderr,
137                    exit_code,
138                });
139            }
140            Ok(None) => {
141                // Still running
142                if Instant::now() >= deadline {
143                    // Kill the process and reap it
144                    let _ = child.kill();
145                    let _ = child.wait();
146                    return Err(FormatError::Timeout {
147                        tool: command.to_string(),
148                        timeout_secs,
149                    });
150                }
151                thread::sleep(Duration::from_millis(50));
152            }
153            Err(e) => {
154                return Err(FormatError::Failed {
155                    tool: command.to_string(),
156                    stderr: format!("try_wait error: {}", e),
157                });
158            }
159        }
160    }
161}
162
163/// TTL for tool availability cache entries.
164const TOOL_CACHE_TTL: Duration = Duration::from_secs(60);
165
166static TOOL_CACHE: std::sync::LazyLock<Mutex<HashMap<String, (bool, Instant)>>> =
167    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
168
169fn tool_cache_key(command: &str, project_root: Option<&Path>) -> String {
170    let root = project_root
171        .map(|path| path.to_string_lossy())
172        .unwrap_or_default();
173    format!("{}\0{}", command, root)
174}
175
176pub fn clear_tool_cache() {
177    if let Ok(mut cache) = TOOL_CACHE.lock() {
178        cache.clear();
179    }
180}
181
182/// Resolve a tool by checking node_modules/.bin relative to project_root, then PATH.
183/// Returns the full path to the tool if found, otherwise None.
184fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
185    // 1. Check node_modules/.bin/<command> relative to project root
186    if let Some(root) = project_root {
187        let local_bin = root.join("node_modules").join(".bin").join(command);
188        if local_bin.exists() {
189            return Some(local_bin.to_string_lossy().to_string());
190        }
191    }
192
193    // 2. Fall back to PATH lookup
194    match Command::new(command)
195        .arg("--version")
196        .stdin(Stdio::null())
197        .stdout(Stdio::null())
198        .stderr(Stdio::null())
199        .spawn()
200    {
201        Ok(mut child) => {
202            let start = Instant::now();
203            let timeout = Duration::from_secs(2);
204            loop {
205                match child.try_wait() {
206                    Ok(Some(status)) => {
207                        return if status.success() {
208                            Some(command.to_string())
209                        } else {
210                            None
211                        };
212                    }
213                    Ok(None) if start.elapsed() > timeout => {
214                        let _ = child.kill();
215                        let _ = child.wait();
216                        return None;
217                    }
218                    Ok(None) => thread::sleep(Duration::from_millis(50)),
219                    Err(_) => return None,
220                }
221            }
222        }
223        Err(_) => None,
224    }
225}
226
227/// Check if `ruff format` is available with a stable formatter.
228///
229/// Ruff's formatter became stable in v0.1.2. Versions before that output
230/// `NOT_YET_IMPLEMENTED_*` stubs instead of formatted code. We parse the
231/// version from `ruff --version` (format: "ruff X.Y.Z") and require >= 0.1.2.
232/// Falls back to false if ruff is not found or version cannot be parsed.
233fn ruff_format_available(project_root: Option<&Path>) -> bool {
234    let key = tool_cache_key("ruff-format", project_root);
235    if let Ok(cache) = TOOL_CACHE.lock() {
236        if let Some((available, checked_at)) = cache.get(&key) {
237            if checked_at.elapsed() < TOOL_CACHE_TTL {
238                return *available;
239            }
240        }
241    }
242
243    let result = ruff_format_available_uncached(project_root);
244    if let Ok(mut cache) = TOOL_CACHE.lock() {
245        cache.insert(key, (result, Instant::now()));
246    }
247    result
248}
249
250fn ruff_format_available_uncached(project_root: Option<&Path>) -> bool {
251    let command = match resolve_tool("ruff", project_root) {
252        Some(command) => command,
253        None => return false,
254    };
255    let output = match Command::new(&command)
256        .arg("--version")
257        .stdout(Stdio::piped())
258        .stderr(Stdio::null())
259        .output()
260    {
261        Ok(o) => o,
262        Err(_) => return false,
263    };
264
265    let version_str = String::from_utf8_lossy(&output.stdout);
266    // Parse "ruff X.Y.Z" or just "X.Y.Z"
267    let version_part = version_str
268        .trim()
269        .strip_prefix("ruff ")
270        .unwrap_or(version_str.trim());
271
272    let parts: Vec<&str> = version_part.split('.').collect();
273    if parts.len() < 3 {
274        return false;
275    }
276
277    let major: u32 = match parts[0].parse() {
278        Ok(v) => v,
279        Err(_) => return false,
280    };
281    let minor: u32 = match parts[1].parse() {
282        Ok(v) => v,
283        Err(_) => return false,
284    };
285    let patch: u32 = match parts[2].parse() {
286        Ok(v) => v,
287        Err(_) => return false,
288    };
289
290    // Require >= 0.1.2 where ruff format became stable
291    (major, minor, patch) >= (0, 1, 2)
292}
293
294fn resolve_candidate_tool(
295    candidate: &ToolCandidate,
296    project_root: Option<&Path>,
297) -> Option<String> {
298    if candidate.tool == "ruff" && !ruff_format_available(project_root) {
299        return None;
300    }
301
302    resolve_tool(&candidate.tool, project_root)
303}
304
305fn lang_key(lang: LangId) -> &'static str {
306    match lang {
307        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
308        LangId::Python => "python",
309        LangId::Rust => "rust",
310        LangId::Go => "go",
311        LangId::C => "c",
312        LangId::Cpp => "cpp",
313        LangId::Zig => "zig",
314        LangId::CSharp => "csharp",
315        LangId::Bash => "bash",
316        LangId::Html => "html",
317        LangId::Markdown => "markdown",
318    }
319}
320
321fn has_formatter_support(lang: LangId) -> bool {
322    matches!(
323        lang,
324        LangId::TypeScript
325            | LangId::JavaScript
326            | LangId::Tsx
327            | LangId::Python
328            | LangId::Rust
329            | LangId::Go
330    )
331}
332
333fn has_checker_support(lang: LangId) -> bool {
334    matches!(
335        lang,
336        LangId::TypeScript
337            | LangId::JavaScript
338            | LangId::Tsx
339            | LangId::Python
340            | LangId::Rust
341            | LangId::Go
342    )
343}
344
345fn formatter_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
346    let project_root = config.project_root.as_deref();
347    if let Some(preferred) = config.formatter.get(lang_key(lang)) {
348        return explicit_formatter_candidate(preferred, file_str);
349    }
350
351    match lang {
352        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
353            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
354                vec![ToolCandidate {
355                    tool: "biome".to_string(),
356                    source: "biome.json".to_string(),
357                    args: vec![
358                        "format".to_string(),
359                        "--write".to_string(),
360                        file_str.to_string(),
361                    ],
362                    required: true,
363                }]
364            } else if has_project_config(
365                project_root,
366                &[
367                    ".prettierrc",
368                    ".prettierrc.json",
369                    ".prettierrc.yml",
370                    ".prettierrc.yaml",
371                    ".prettierrc.js",
372                    ".prettierrc.cjs",
373                    ".prettierrc.mjs",
374                    ".prettierrc.toml",
375                    "prettier.config.js",
376                    "prettier.config.cjs",
377                    "prettier.config.mjs",
378                ],
379            ) {
380                vec![ToolCandidate {
381                    tool: "prettier".to_string(),
382                    source: "Prettier config".to_string(),
383                    args: vec!["--write".to_string(), file_str.to_string()],
384                    required: true,
385                }]
386            } else if has_project_config(project_root, &["deno.json", "deno.jsonc"]) {
387                vec![ToolCandidate {
388                    tool: "deno".to_string(),
389                    source: "deno.json".to_string(),
390                    args: vec!["fmt".to_string(), file_str.to_string()],
391                    required: true,
392                }]
393            } else {
394                Vec::new()
395            }
396        }
397        LangId::Python => {
398            if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
399                || has_pyproject_tool(project_root, "ruff")
400            {
401                vec![ToolCandidate {
402                    tool: "ruff".to_string(),
403                    source: "ruff config".to_string(),
404                    args: vec!["format".to_string(), file_str.to_string()],
405                    required: true,
406                }]
407            } else if has_pyproject_tool(project_root, "black") {
408                vec![ToolCandidate {
409                    tool: "black".to_string(),
410                    source: "pyproject.toml".to_string(),
411                    args: vec![file_str.to_string()],
412                    required: true,
413                }]
414            } else {
415                Vec::new()
416            }
417        }
418        LangId::Rust => {
419            if has_project_config(project_root, &["Cargo.toml"]) {
420                vec![ToolCandidate {
421                    tool: "rustfmt".to_string(),
422                    source: "Cargo.toml".to_string(),
423                    args: vec![file_str.to_string()],
424                    required: true,
425                }]
426            } else {
427                Vec::new()
428            }
429        }
430        LangId::Go => {
431            if has_project_config(project_root, &["go.mod"]) {
432                vec![
433                    ToolCandidate {
434                        tool: "goimports".to_string(),
435                        source: "go.mod".to_string(),
436                        args: vec!["-w".to_string(), file_str.to_string()],
437                        required: false,
438                    },
439                    ToolCandidate {
440                        tool: "gofmt".to_string(),
441                        source: "go.mod".to_string(),
442                        args: vec!["-w".to_string(), file_str.to_string()],
443                        required: true,
444                    },
445                ]
446            } else {
447                Vec::new()
448            }
449        }
450        LangId::C | LangId::Cpp | LangId::Zig | LangId::CSharp | LangId::Bash => Vec::new(),
451        LangId::Html => Vec::new(),
452        LangId::Markdown => Vec::new(),
453    }
454}
455
456fn checker_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
457    let project_root = config.project_root.as_deref();
458    if let Some(preferred) = config.checker.get(lang_key(lang)) {
459        return explicit_checker_candidate(preferred, file_str);
460    }
461
462    match lang {
463        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
464            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
465                vec![ToolCandidate {
466                    tool: "biome".to_string(),
467                    source: "biome.json".to_string(),
468                    args: vec!["check".to_string(), file_str.to_string()],
469                    required: true,
470                }]
471            } else if has_project_config(project_root, &["tsconfig.json"]) {
472                vec![ToolCandidate {
473                    tool: "tsc".to_string(),
474                    source: "tsconfig.json".to_string(),
475                    args: vec![
476                        "--noEmit".to_string(),
477                        "--pretty".to_string(),
478                        "false".to_string(),
479                    ],
480                    required: true,
481                }]
482            } else {
483                Vec::new()
484            }
485        }
486        LangId::Python => {
487            if has_project_config(project_root, &["pyrightconfig.json"])
488                || has_pyproject_tool(project_root, "pyright")
489            {
490                vec![ToolCandidate {
491                    tool: "pyright".to_string(),
492                    source: "pyright config".to_string(),
493                    args: vec!["--outputjson".to_string(), file_str.to_string()],
494                    required: true,
495                }]
496            } else if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
497                || has_pyproject_tool(project_root, "ruff")
498            {
499                vec![ToolCandidate {
500                    tool: "ruff".to_string(),
501                    source: "ruff config".to_string(),
502                    args: vec![
503                        "check".to_string(),
504                        "--output-format=json".to_string(),
505                        file_str.to_string(),
506                    ],
507                    required: true,
508                }]
509            } else {
510                Vec::new()
511            }
512        }
513        LangId::Rust => {
514            if has_project_config(project_root, &["Cargo.toml"]) {
515                vec![ToolCandidate {
516                    tool: "cargo".to_string(),
517                    source: "Cargo.toml".to_string(),
518                    args: vec!["check".to_string(), "--message-format=json".to_string()],
519                    required: true,
520                }]
521            } else {
522                Vec::new()
523            }
524        }
525        LangId::Go => {
526            if has_project_config(project_root, &["go.mod"]) {
527                vec![
528                    ToolCandidate {
529                        tool: "staticcheck".to_string(),
530                        source: "go.mod".to_string(),
531                        args: vec![file_str.to_string()],
532                        required: false,
533                    },
534                    ToolCandidate {
535                        tool: "go".to_string(),
536                        source: "go.mod".to_string(),
537                        args: vec!["vet".to_string(), file_str.to_string()],
538                        required: true,
539                    },
540                ]
541            } else {
542                Vec::new()
543            }
544        }
545        LangId::C | LangId::Cpp | LangId::Zig | LangId::CSharp | LangId::Bash => Vec::new(),
546        LangId::Html => Vec::new(),
547        LangId::Markdown => Vec::new(),
548    }
549}
550
551fn explicit_formatter_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
552    match name {
553        "none" | "off" | "false" => Vec::new(),
554        "biome" => vec![ToolCandidate {
555            tool: name.to_string(),
556            source: "formatter config".to_string(),
557            args: vec![
558                "format".to_string(),
559                "--write".to_string(),
560                file_str.to_string(),
561            ],
562            required: true,
563        }],
564        "prettier" => vec![ToolCandidate {
565            tool: name.to_string(),
566            source: "formatter config".to_string(),
567            args: vec!["--write".to_string(), file_str.to_string()],
568            required: true,
569        }],
570        "deno" => vec![ToolCandidate {
571            tool: name.to_string(),
572            source: "formatter config".to_string(),
573            args: vec!["fmt".to_string(), file_str.to_string()],
574            required: true,
575        }],
576        "ruff" => vec![ToolCandidate {
577            tool: name.to_string(),
578            source: "formatter config".to_string(),
579            args: vec!["format".to_string(), file_str.to_string()],
580            required: true,
581        }],
582        "black" | "rustfmt" => vec![ToolCandidate {
583            tool: name.to_string(),
584            source: "formatter config".to_string(),
585            args: vec![file_str.to_string()],
586            required: true,
587        }],
588        "goimports" | "gofmt" => vec![ToolCandidate {
589            tool: name.to_string(),
590            source: "formatter config".to_string(),
591            args: vec!["-w".to_string(), file_str.to_string()],
592            required: true,
593        }],
594        _ => Vec::new(),
595    }
596}
597
598fn explicit_checker_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
599    match name {
600        "none" | "off" | "false" => Vec::new(),
601        "tsc" => vec![ToolCandidate {
602            tool: name.to_string(),
603            source: "checker config".to_string(),
604            args: vec![
605                "--noEmit".to_string(),
606                "--pretty".to_string(),
607                "false".to_string(),
608            ],
609            required: true,
610        }],
611        "cargo" => vec![ToolCandidate {
612            tool: name.to_string(),
613            source: "checker config".to_string(),
614            args: vec!["check".to_string(), "--message-format=json".to_string()],
615            required: true,
616        }],
617        "go" => vec![ToolCandidate {
618            tool: name.to_string(),
619            source: "checker config".to_string(),
620            args: vec!["vet".to_string(), file_str.to_string()],
621            required: true,
622        }],
623        "biome" => vec![ToolCandidate {
624            tool: name.to_string(),
625            source: "checker config".to_string(),
626            args: vec!["check".to_string(), file_str.to_string()],
627            required: true,
628        }],
629        "pyright" => vec![ToolCandidate {
630            tool: name.to_string(),
631            source: "checker config".to_string(),
632            args: vec!["--outputjson".to_string(), file_str.to_string()],
633            required: true,
634        }],
635        "ruff" => vec![ToolCandidate {
636            tool: name.to_string(),
637            source: "checker config".to_string(),
638            args: vec![
639                "check".to_string(),
640                "--output-format=json".to_string(),
641                file_str.to_string(),
642            ],
643            required: true,
644        }],
645        "staticcheck" => vec![ToolCandidate {
646            tool: name.to_string(),
647            source: "checker config".to_string(),
648            args: vec![file_str.to_string()],
649            required: true,
650        }],
651        _ => Vec::new(),
652    }
653}
654
655fn resolve_tool_candidates(
656    candidates: Vec<ToolCandidate>,
657    project_root: Option<&Path>,
658) -> ToolDetection {
659    if candidates.is_empty() {
660        return ToolDetection::NotConfigured;
661    }
662
663    let mut missing_required = None;
664    for candidate in candidates {
665        if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
666            return ToolDetection::Found(command, candidate.args);
667        }
668        if candidate.required && missing_required.is_none() {
669            missing_required = Some(candidate.tool);
670        }
671    }
672
673    match missing_required {
674        Some(tool) => ToolDetection::NotInstalled { tool },
675        None => ToolDetection::NotConfigured,
676    }
677}
678
679fn checker_command(candidate: &ToolCandidate, resolved: String) -> String {
680    match candidate.tool.as_str() {
681        "tsc" => resolved,
682        "cargo" => "cargo".to_string(),
683        "go" => "go".to_string(),
684        _ => resolved,
685    }
686}
687
688fn checker_args(candidate: &ToolCandidate) -> Vec<String> {
689    if candidate.tool == "tsc" {
690        vec![
691            "--noEmit".to_string(),
692            "--pretty".to_string(),
693            "false".to_string(),
694        ]
695    } else {
696        candidate.args.clone()
697    }
698}
699
700fn detect_formatter_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
701    let file_str = path.to_string_lossy().to_string();
702    resolve_tool_candidates(
703        formatter_candidates(lang, config, &file_str),
704        config.project_root.as_deref(),
705    )
706}
707
708fn detect_checker_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
709    let file_str = path.to_string_lossy().to_string();
710    let candidates = checker_candidates(lang, config, &file_str);
711    if candidates.is_empty() {
712        return ToolDetection::NotConfigured;
713    }
714
715    let project_root = config.project_root.as_deref();
716    let mut missing_required = None;
717    for candidate in candidates {
718        if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
719            return ToolDetection::Found(
720                checker_command(&candidate, command),
721                checker_args(&candidate),
722            );
723        }
724        if candidate.required && missing_required.is_none() {
725            missing_required = Some(candidate.tool);
726        }
727    }
728
729    match missing_required {
730        Some(tool) => ToolDetection::NotInstalled { tool },
731        None => ToolDetection::NotConfigured,
732    }
733}
734
735fn languages_in_project(project_root: &Path) -> HashSet<LangId> {
736    crate::callgraph::walk_project_files(project_root)
737        .filter_map(|path| detect_language(&path))
738        .collect()
739}
740
741fn placeholder_file_for_language(project_root: &Path, lang: LangId) -> PathBuf {
742    let filename = match lang {
743        LangId::TypeScript => "aft-tool-detection.ts",
744        LangId::Tsx => "aft-tool-detection.tsx",
745        LangId::JavaScript => "aft-tool-detection.js",
746        LangId::Python => "aft-tool-detection.py",
747        LangId::Rust => "aft_tool_detection.rs",
748        LangId::Go => "aft_tool_detection.go",
749        LangId::C => "aft_tool_detection.c",
750        LangId::Cpp => "aft_tool_detection.cpp",
751        LangId::Zig => "aft_tool_detection.zig",
752        LangId::CSharp => "aft_tool_detection.cs",
753        LangId::Bash => "aft_tool_detection.sh",
754        LangId::Html => "aft-tool-detection.html",
755        LangId::Markdown => "aft-tool-detection.md",
756    };
757    project_root.join(filename)
758}
759
760pub(crate) fn install_hint(tool: &str) -> String {
761    match tool {
762        "biome" => {
763            "Run `bun add -d --workspace-root @biomejs/biome` or install globally.".to_string()
764        }
765        "prettier" => "Run `npm install -D prettier` or install globally.".to_string(),
766        "tsc" => "Run `npm install -D typescript` or install globally.".to_string(),
767        "pyright" | "pyright-langserver" => "Install: `npm install -g pyright`".to_string(),
768        "ruff" => {
769            "Install: `pip install ruff` or your Python package manager equivalent.".to_string()
770        }
771        "black" => {
772            "Install: `pip install black` or your Python package manager equivalent.".to_string()
773        }
774        "rustfmt" => "Install: `rustup component add rustfmt`".to_string(),
775        "rust-analyzer" => "Install: `rustup component add rust-analyzer`".to_string(),
776        "cargo" => "Install Rust from https://rustup.rs/.".to_string(),
777        "go" => "Install Go from https://go.dev/dl/.".to_string(),
778        "gopls" => "Install: `go install golang.org/x/tools/gopls@latest`".to_string(),
779        "bash-language-server" => "Install: `npm install -g bash-language-server`".to_string(),
780        "yaml-language-server" => "Install: `npm install -g yaml-language-server`".to_string(),
781        "typescript-language-server" => {
782            "Install: `npm install -g typescript-language-server typescript`".to_string()
783        }
784        "deno" => "Install Deno from https://deno.com/.".to_string(),
785        "goimports" => "Install: `go install golang.org/x/tools/cmd/goimports@latest`".to_string(),
786        "staticcheck" => {
787            "Install: `go install honnef.co/go/tools/cmd/staticcheck@latest`".to_string()
788        }
789        other => format!("Install `{other}` and ensure it is on PATH."),
790    }
791}
792
793fn configured_tool_hint(tool: &str, source: &str) -> String {
794    format!(
795        "{tool} is configured in {source} but not installed. {}",
796        install_hint(tool)
797    )
798}
799
800fn missing_tool_warning(
801    kind: &str,
802    language: &str,
803    candidate: &ToolCandidate,
804    project_root: Option<&Path>,
805) -> Option<MissingTool> {
806    if !candidate.required || resolve_candidate_tool(candidate, project_root).is_some() {
807        return None;
808    }
809
810    Some(MissingTool {
811        kind: kind.to_string(),
812        language: language.to_string(),
813        tool: candidate.tool.clone(),
814        hint: configured_tool_hint(&candidate.tool, &candidate.source),
815    })
816}
817
818/// Detect configured formatters/checkers that are missing for languages present in the project.
819pub fn detect_missing_tools(project_root: &Path, config: &Config) -> Vec<MissingTool> {
820    let languages = languages_in_project(project_root);
821    let mut warnings = Vec::new();
822    let mut seen = HashSet::new();
823
824    for lang in languages {
825        let language = lang_key(lang);
826        let placeholder = placeholder_file_for_language(project_root, lang);
827        let file_str = placeholder.to_string_lossy().to_string();
828
829        for candidate in formatter_candidates(lang, config, &file_str) {
830            if let Some(warning) = missing_tool_warning(
831                "formatter_not_installed",
832                language,
833                &candidate,
834                config.project_root.as_deref(),
835            ) {
836                if seen.insert((
837                    warning.kind.clone(),
838                    warning.language.clone(),
839                    warning.tool.clone(),
840                )) {
841                    warnings.push(warning);
842                }
843            }
844        }
845
846        for candidate in checker_candidates(lang, config, &file_str) {
847            if let Some(warning) = missing_tool_warning(
848                "checker_not_installed",
849                language,
850                &candidate,
851                config.project_root.as_deref(),
852            ) {
853                if seen.insert((
854                    warning.kind.clone(),
855                    warning.language.clone(),
856                    warning.tool.clone(),
857                )) {
858                    warnings.push(warning);
859                }
860            }
861        }
862    }
863
864    warnings.sort_by(|left, right| {
865        (&left.kind, &left.language, &left.tool).cmp(&(&right.kind, &right.language, &right.tool))
866    });
867    warnings
868}
869
870/// Detect the appropriate formatter command and arguments for a file.
871///
872/// Priority per language:
873/// - TypeScript/JavaScript/TSX: `prettier --write <file>`
874/// - Python: `ruff format <file>` (fallback: `black <file>`)
875/// - Rust: `rustfmt <file>`
876/// - Go: `gofmt -w <file>`
877///
878/// Returns `None` if no formatter is available for the language.
879pub fn detect_formatter(
880    path: &Path,
881    lang: LangId,
882    config: &Config,
883) -> Option<(String, Vec<String>)> {
884    match detect_formatter_for_path(path, lang, config) {
885        ToolDetection::Found(cmd, args) => Some((cmd, args)),
886        ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
887    }
888}
889
890/// Check if any of the given config file names exist in the project root.
891fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
892    let root = match project_root {
893        Some(r) => r,
894        None => return false,
895    };
896    filenames.iter().any(|f| root.join(f).exists())
897}
898
899/// Check if pyproject.toml exists and contains a `[tool.<name>]` section.
900fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
901    let root = match project_root {
902        Some(r) => r,
903        None => return false,
904    };
905    let pyproject = root.join("pyproject.toml");
906    if !pyproject.exists() {
907        return false;
908    }
909    match std::fs::read_to_string(&pyproject) {
910        Ok(content) => {
911            let pattern = format!("[tool.{}]", tool_name);
912            content.contains(&pattern)
913        }
914        Err(_) => false,
915    }
916}
917
918/// Auto-format a file using the detected formatter for its language.
919///
920/// Returns `(formatted, skip_reason)`:
921/// - `(true, None)` — file was successfully formatted
922/// - `(false, Some(reason))` — formatting was skipped, reason explains why
923///
924/// Skip reasons: `"unsupported_language"`, `"no_formatter_configured"`,
925/// `"formatter_not_installed"`, `"timeout"`, `"error"`
926pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
927    // Check if formatting is disabled via plugin config
928    if !config.format_on_edit {
929        return (false, Some("no_formatter_configured".to_string()));
930    }
931
932    let lang = match detect_language(path) {
933        Some(l) => l,
934        None => {
935            log::debug!(
936                "[aft] format: {} (skipped: unsupported_language)",
937                path.display()
938            );
939            return (false, Some("unsupported_language".to_string()));
940        }
941    };
942    if !has_formatter_support(lang) {
943        log::debug!(
944            "[aft] format: {} (skipped: unsupported_language)",
945            path.display()
946        );
947        return (false, Some("unsupported_language".to_string()));
948    }
949
950    let (cmd, args) = match detect_formatter_for_path(path, lang, config) {
951        ToolDetection::Found(cmd, args) => (cmd, args),
952        ToolDetection::NotConfigured => {
953            log::debug!(
954                "[aft] format: {} (skipped: no_formatter_configured)",
955                path.display()
956            );
957            return (false, Some("no_formatter_configured".to_string()));
958        }
959        ToolDetection::NotInstalled { tool } => {
960            log::warn!(
961                "format: {} (skipped: formatter_not_installed: {})",
962                path.display(),
963                tool
964            );
965            return (false, Some("formatter_not_installed".to_string()));
966        }
967    };
968
969    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
970
971    // Run the formatter in the project root so tool-local config files
972    // (biome.json, .prettierrc, rustfmt.toml, etc.) are discovered. The
973    // type-checker path (`validate_full`) already does this via
974    // `path.parent()`; formatters need the same treatment. Without it,
975    // formatters silently fall back to built-in defaults when the aft
976    // process CWD differs from the project root (audit #18).
977    let working_dir = config.project_root.as_deref();
978
979    match run_external_tool(&cmd, &arg_refs, working_dir, config.formatter_timeout_secs) {
980        Ok(_) => {
981            log::info!("format: {} ({})", path.display(), cmd);
982            (true, None)
983        }
984        Err(FormatError::Timeout { .. }) => {
985            log::warn!("format: {} (skipped: timeout)", path.display());
986            (false, Some("timeout".to_string()))
987        }
988        Err(FormatError::NotFound { .. }) => {
989            log::warn!(
990                "format: {} (skipped: formatter_not_installed)",
991                path.display()
992            );
993            (false, Some("formatter_not_installed".to_string()))
994        }
995        Err(FormatError::Failed { stderr, .. }) => {
996            log::debug!(
997                "[aft] format: {} (skipped: error: {})",
998                path.display(),
999                stderr.lines().next().unwrap_or("unknown")
1000            );
1001            (false, Some("error".to_string()))
1002        }
1003        Err(FormatError::UnsupportedLanguage) => {
1004            log::debug!(
1005                "[aft] format: {} (skipped: unsupported_language)",
1006                path.display()
1007            );
1008            (false, Some("unsupported_language".to_string()))
1009        }
1010    }
1011}
1012
1013/// Spawn a subprocess and capture output regardless of exit code.
1014///
1015/// Unlike `run_external_tool`, this does NOT treat non-zero exit as an error —
1016/// type checkers return non-zero when they find issues, which is expected.
1017/// Returns `FormatError::NotFound` when the binary isn't on PATH, and
1018/// `FormatError::Timeout` if the deadline is exceeded.
1019pub fn run_external_tool_capture(
1020    command: &str,
1021    args: &[&str],
1022    working_dir: Option<&Path>,
1023    timeout_secs: u32,
1024) -> Result<ExternalToolResult, FormatError> {
1025    let mut cmd = Command::new(command);
1026    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
1027
1028    if let Some(dir) = working_dir {
1029        cmd.current_dir(dir);
1030    }
1031
1032    let mut child = match cmd.spawn() {
1033        Ok(c) => c,
1034        Err(e) if e.kind() == ErrorKind::NotFound => {
1035            return Err(FormatError::NotFound {
1036                tool: command.to_string(),
1037            });
1038        }
1039        Err(e) => {
1040            return Err(FormatError::Failed {
1041                tool: command.to_string(),
1042                stderr: e.to_string(),
1043            });
1044        }
1045    };
1046
1047    let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
1048
1049    loop {
1050        match child.try_wait() {
1051            Ok(Some(status)) => {
1052                let stdout = child
1053                    .stdout
1054                    .take()
1055                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
1056                    .unwrap_or_default();
1057                let stderr = child
1058                    .stderr
1059                    .take()
1060                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
1061                    .unwrap_or_default();
1062
1063                return Ok(ExternalToolResult {
1064                    stdout,
1065                    stderr,
1066                    exit_code: status.code().unwrap_or(-1),
1067                });
1068            }
1069            Ok(None) => {
1070                if Instant::now() >= deadline {
1071                    let _ = child.kill();
1072                    let _ = child.wait();
1073                    return Err(FormatError::Timeout {
1074                        tool: command.to_string(),
1075                        timeout_secs,
1076                    });
1077                }
1078                thread::sleep(Duration::from_millis(50));
1079            }
1080            Err(e) => {
1081                return Err(FormatError::Failed {
1082                    tool: command.to_string(),
1083                    stderr: format!("try_wait error: {}", e),
1084                });
1085            }
1086        }
1087    }
1088}
1089
1090// ============================================================================
1091// Type-checker validation (R017)
1092// ============================================================================
1093
1094/// A structured error from a type checker.
1095#[derive(Debug, Clone, serde::Serialize)]
1096pub struct ValidationError {
1097    pub line: u32,
1098    pub column: u32,
1099    pub message: String,
1100    pub severity: String,
1101}
1102
1103/// Detect the appropriate type checker command and arguments for a file.
1104///
1105/// Returns `(command, args)` for the type checker. The `--noEmit` / equivalent
1106/// flags ensure no output files are produced.
1107///
1108/// Supported:
1109/// - TypeScript/JavaScript/TSX → `npx tsc --noEmit` (fallback: `tsc --noEmit`)
1110/// - Python → `pyright`
1111/// - Rust → `cargo check`
1112/// - Go → `go vet`
1113pub fn detect_type_checker(
1114    path: &Path,
1115    lang: LangId,
1116    config: &Config,
1117) -> Option<(String, Vec<String>)> {
1118    match detect_checker_for_path(path, lang, config) {
1119        ToolDetection::Found(cmd, args) => Some((cmd, args)),
1120        ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1121    }
1122}
1123
1124/// Parse type checker output into structured validation errors.
1125///
1126/// Handles output formats from tsc, pyright (JSON), cargo check (JSON), and go vet.
1127/// Filters to errors related to the edited file where feasible.
1128pub fn parse_checker_output(
1129    stdout: &str,
1130    stderr: &str,
1131    file: &Path,
1132    checker: &str,
1133) -> Vec<ValidationError> {
1134    let checker_name = Path::new(checker)
1135        .file_name()
1136        .and_then(|name| name.to_str())
1137        .unwrap_or(checker);
1138    match checker_name {
1139        "npx" | "tsc" => parse_tsc_output(stdout, stderr, file),
1140        "pyright" => parse_pyright_output(stdout, file),
1141        "cargo" => parse_cargo_output(stdout, stderr, file),
1142        "go" => parse_go_vet_output(stderr, file),
1143        _ => Vec::new(),
1144    }
1145}
1146
1147/// Parse tsc output lines like: `path(line,col): error TSxxxx: message`
1148fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1149    let mut errors = Vec::new();
1150    let file_str = file.to_string_lossy();
1151    // tsc writes diagnostics to stdout (with --pretty false)
1152    let combined = format!("{}{}", stdout, stderr);
1153    for line in combined.lines() {
1154        // Format: path(line,col): severity TSxxxx: message
1155        // or: path(line,col): severity: message
1156        if let Some((loc, rest)) = line.split_once("): ") {
1157            // Check if this error is for our file (compare filename part)
1158            let file_part = loc.split('(').next().unwrap_or("");
1159            if !file_str.ends_with(file_part)
1160                && !file_part.ends_with(&*file_str)
1161                && file_part != &*file_str
1162            {
1163                continue;
1164            }
1165
1166            // Parse (line,col) from the location part
1167            let coords = loc.split('(').last().unwrap_or("");
1168            let parts: Vec<&str> = coords.split(',').collect();
1169            let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
1170            let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1171
1172            // Parse severity and message
1173            let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
1174                ("error".to_string(), msg.to_string())
1175            } else if let Some(msg) = rest.strip_prefix("warning ") {
1176                ("warning".to_string(), msg.to_string())
1177            } else {
1178                ("error".to_string(), rest.to_string())
1179            };
1180
1181            errors.push(ValidationError {
1182                line: line_num,
1183                column: col_num,
1184                message,
1185                severity,
1186            });
1187        }
1188    }
1189    errors
1190}
1191
1192/// Parse pyright JSON output.
1193fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
1194    let mut errors = Vec::new();
1195    let file_str = file.to_string_lossy();
1196
1197    // pyright --outputjson emits JSON with generalDiagnostics array
1198    if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
1199        if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
1200            for diag in diags {
1201                // Filter to our file
1202                let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
1203                if !diag_file.is_empty()
1204                    && !file_str.ends_with(diag_file)
1205                    && !diag_file.ends_with(&*file_str)
1206                    && diag_file != &*file_str
1207                {
1208                    continue;
1209                }
1210
1211                let line_num = diag
1212                    .get("range")
1213                    .and_then(|r| r.get("start"))
1214                    .and_then(|s| s.get("line"))
1215                    .and_then(|l| l.as_u64())
1216                    .unwrap_or(0) as u32;
1217                let col_num = diag
1218                    .get("range")
1219                    .and_then(|r| r.get("start"))
1220                    .and_then(|s| s.get("character"))
1221                    .and_then(|c| c.as_u64())
1222                    .unwrap_or(0) as u32;
1223                let message = diag
1224                    .get("message")
1225                    .and_then(|m| m.as_str())
1226                    .unwrap_or("unknown error")
1227                    .to_string();
1228                let severity = diag
1229                    .get("severity")
1230                    .and_then(|s| s.as_str())
1231                    .unwrap_or("error")
1232                    .to_lowercase();
1233
1234                errors.push(ValidationError {
1235                    line: line_num + 1, // pyright uses 0-indexed lines
1236                    column: col_num,
1237                    message,
1238                    severity,
1239                });
1240            }
1241        }
1242    }
1243    errors
1244}
1245
1246/// Parse cargo check JSON output, filtering to errors in the target file.
1247fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
1248    let mut errors = Vec::new();
1249    let file_str = file.to_string_lossy();
1250
1251    for line in stdout.lines() {
1252        if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
1253            if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
1254                continue;
1255            }
1256            let message_obj = match msg.get("message") {
1257                Some(m) => m,
1258                None => continue,
1259            };
1260
1261            let level = message_obj
1262                .get("level")
1263                .and_then(|l| l.as_str())
1264                .unwrap_or("error");
1265
1266            // Only include errors and warnings, skip notes/help
1267            if level != "error" && level != "warning" {
1268                continue;
1269            }
1270
1271            let text = message_obj
1272                .get("message")
1273                .and_then(|m| m.as_str())
1274                .unwrap_or("unknown error")
1275                .to_string();
1276
1277            // Find the primary span for our file
1278            if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
1279                for span in spans {
1280                    let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
1281                    let is_primary = span
1282                        .get("is_primary")
1283                        .and_then(|p| p.as_bool())
1284                        .unwrap_or(false);
1285
1286                    if !is_primary {
1287                        continue;
1288                    }
1289
1290                    // Filter to our file
1291                    if !file_str.ends_with(span_file)
1292                        && !span_file.ends_with(&*file_str)
1293                        && span_file != &*file_str
1294                    {
1295                        continue;
1296                    }
1297
1298                    let line_num =
1299                        span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
1300                    let col_num = span
1301                        .get("column_start")
1302                        .and_then(|c| c.as_u64())
1303                        .unwrap_or(0) as u32;
1304
1305                    errors.push(ValidationError {
1306                        line: line_num,
1307                        column: col_num,
1308                        message: text.clone(),
1309                        severity: level.to_string(),
1310                    });
1311                }
1312            }
1313        }
1314    }
1315    errors
1316}
1317
1318/// Parse go vet output lines like: `path:line:col: message`
1319fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
1320    let mut errors = Vec::new();
1321    let file_str = file.to_string_lossy();
1322
1323    for line in stderr.lines() {
1324        // Format: path:line:col: message  OR  path:line: message
1325        let parts: Vec<&str> = line.splitn(4, ':').collect();
1326        if parts.len() < 3 {
1327            continue;
1328        }
1329
1330        let err_file = parts[0].trim();
1331        if !file_str.ends_with(err_file)
1332            && !err_file.ends_with(&*file_str)
1333            && err_file != &*file_str
1334        {
1335            continue;
1336        }
1337
1338        let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
1339        let (col_num, message) = if parts.len() >= 4 {
1340            if let Ok(col) = parts[2].trim().parse::<u32>() {
1341                (col, parts[3].trim().to_string())
1342            } else {
1343                // parts[2] is part of the message, not a column
1344                (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
1345            }
1346        } else {
1347            (0, parts[2].trim().to_string())
1348        };
1349
1350        errors.push(ValidationError {
1351            line: line_num,
1352            column: col_num,
1353            message,
1354            severity: "error".to_string(),
1355        });
1356    }
1357    errors
1358}
1359
1360/// Run the project's type checker and return structured validation errors.
1361///
1362/// Returns `(errors, skip_reason)`:
1363/// - `(errors, None)` — checker ran, errors may be empty (= valid code)
1364/// - `([], Some(reason))` — checker was skipped
1365///
1366/// Skip reasons: `"unsupported_language"`, `"no_checker_configured"`,
1367/// `"checker_not_installed"`, `"timeout"`, `"error"`
1368pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
1369    let lang = match detect_language(path) {
1370        Some(l) => l,
1371        None => {
1372            log::debug!(
1373                "[aft] validate: {} (skipped: unsupported_language)",
1374                path.display()
1375            );
1376            return (Vec::new(), Some("unsupported_language".to_string()));
1377        }
1378    };
1379    if !has_checker_support(lang) {
1380        log::debug!(
1381            "[aft] validate: {} (skipped: unsupported_language)",
1382            path.display()
1383        );
1384        return (Vec::new(), Some("unsupported_language".to_string()));
1385    }
1386
1387    let (cmd, args) = match detect_checker_for_path(path, lang, config) {
1388        ToolDetection::Found(cmd, args) => (cmd, args),
1389        ToolDetection::NotConfigured => {
1390            log::debug!(
1391                "[aft] validate: {} (skipped: no_checker_configured)",
1392                path.display()
1393            );
1394            return (Vec::new(), Some("no_checker_configured".to_string()));
1395        }
1396        ToolDetection::NotInstalled { tool } => {
1397            log::warn!(
1398                "validate: {} (skipped: checker_not_installed: {})",
1399                path.display(),
1400                tool
1401            );
1402            return (Vec::new(), Some("checker_not_installed".to_string()));
1403        }
1404    };
1405
1406    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1407
1408    // Type checkers may need to run from the project root
1409    let working_dir = config.project_root.as_deref();
1410
1411    match run_external_tool_capture(
1412        &cmd,
1413        &arg_refs,
1414        working_dir,
1415        config.type_checker_timeout_secs,
1416    ) {
1417        Ok(result) => {
1418            let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
1419            log::debug!(
1420                "[aft] validate: {} ({}, {} errors)",
1421                path.display(),
1422                cmd,
1423                errors.len()
1424            );
1425            (errors, None)
1426        }
1427        Err(FormatError::Timeout { .. }) => {
1428            log::error!("validate: {} (skipped: timeout)", path.display());
1429            (Vec::new(), Some("timeout".to_string()))
1430        }
1431        Err(FormatError::NotFound { .. }) => {
1432            log::warn!(
1433                "validate: {} (skipped: checker_not_installed)",
1434                path.display()
1435            );
1436            (Vec::new(), Some("checker_not_installed".to_string()))
1437        }
1438        Err(FormatError::Failed { stderr, .. }) => {
1439            log::debug!(
1440                "[aft] validate: {} (skipped: error: {})",
1441                path.display(),
1442                stderr.lines().next().unwrap_or("unknown")
1443            );
1444            (Vec::new(), Some("error".to_string()))
1445        }
1446        Err(FormatError::UnsupportedLanguage) => {
1447            log::debug!(
1448                "[aft] validate: {} (skipped: unsupported_language)",
1449                path.display()
1450            );
1451            (Vec::new(), Some("unsupported_language".to_string()))
1452        }
1453    }
1454}
1455
1456#[cfg(test)]
1457mod tests {
1458    use super::*;
1459    use std::fs;
1460    use std::io::Write;
1461
1462    #[test]
1463    fn run_external_tool_not_found() {
1464        let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1465        assert!(result.is_err());
1466        match result.unwrap_err() {
1467            FormatError::NotFound { tool } => {
1468                assert_eq!(tool, "__nonexistent_tool_xyz__");
1469            }
1470            other => panic!("expected NotFound, got: {:?}", other),
1471        }
1472    }
1473
1474    #[test]
1475    fn run_external_tool_timeout_kills_subprocess() {
1476        // Use `sleep 60` as a long-running process, timeout after 1 second
1477        let result = run_external_tool("sleep", &["60"], None, 1);
1478        assert!(result.is_err());
1479        match result.unwrap_err() {
1480            FormatError::Timeout { tool, timeout_secs } => {
1481                assert_eq!(tool, "sleep");
1482                assert_eq!(timeout_secs, 1);
1483            }
1484            other => panic!("expected Timeout, got: {:?}", other),
1485        }
1486    }
1487
1488    #[test]
1489    fn run_external_tool_success() {
1490        let result = run_external_tool("echo", &["hello"], None, 5);
1491        assert!(result.is_ok());
1492        let res = result.unwrap();
1493        assert_eq!(res.exit_code, 0);
1494        assert!(res.stdout.contains("hello"));
1495    }
1496
1497    #[test]
1498    fn run_external_tool_nonzero_exit() {
1499        // `false` always exits with code 1
1500        let result = run_external_tool("false", &[], None, 5);
1501        assert!(result.is_err());
1502        match result.unwrap_err() {
1503            FormatError::Failed { tool, .. } => {
1504                assert_eq!(tool, "false");
1505            }
1506            other => panic!("expected Failed, got: {:?}", other),
1507        }
1508    }
1509
1510    #[test]
1511    fn auto_format_unsupported_language() {
1512        let dir = tempfile::tempdir().unwrap();
1513        let path = dir.path().join("file.txt");
1514        fs::write(&path, "hello").unwrap();
1515
1516        let config = Config::default();
1517        let (formatted, reason) = auto_format(&path, &config);
1518        assert!(!formatted);
1519        assert_eq!(reason.as_deref(), Some("unsupported_language"));
1520    }
1521
1522    #[test]
1523    fn detect_formatter_rust_when_rustfmt_available() {
1524        let dir = tempfile::tempdir().unwrap();
1525        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1526        let path = dir.path().join("test.rs");
1527        let config = Config {
1528            project_root: Some(dir.path().to_path_buf()),
1529            ..Config::default()
1530        };
1531        let result = detect_formatter(&path, LangId::Rust, &config);
1532        if resolve_tool("rustfmt", config.project_root.as_deref()).is_some() {
1533            let (cmd, args) = result.unwrap();
1534            assert_eq!(cmd, "rustfmt");
1535            assert!(args.iter().any(|a| a.ends_with("test.rs")));
1536        } else {
1537            assert!(result.is_none());
1538        }
1539    }
1540
1541    #[test]
1542    fn detect_formatter_go_mapping() {
1543        let dir = tempfile::tempdir().unwrap();
1544        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1545        let path = dir.path().join("main.go");
1546        let config = Config {
1547            project_root: Some(dir.path().to_path_buf()),
1548            ..Config::default()
1549        };
1550        let result = detect_formatter(&path, LangId::Go, &config);
1551        if resolve_tool("goimports", config.project_root.as_deref()).is_some() {
1552            let (cmd, args) = result.unwrap();
1553            assert_eq!(cmd, "goimports");
1554            assert!(args.contains(&"-w".to_string()));
1555        } else if resolve_tool("gofmt", config.project_root.as_deref()).is_some() {
1556            let (cmd, args) = result.unwrap();
1557            assert_eq!(cmd, "gofmt");
1558            assert!(args.contains(&"-w".to_string()));
1559        } else {
1560            assert!(result.is_none());
1561        }
1562    }
1563
1564    #[test]
1565    fn detect_formatter_python_mapping() {
1566        let dir = tempfile::tempdir().unwrap();
1567        fs::write(dir.path().join("ruff.toml"), "").unwrap();
1568        let path = dir.path().join("main.py");
1569        let config = Config {
1570            project_root: Some(dir.path().to_path_buf()),
1571            ..Config::default()
1572        };
1573        let result = detect_formatter(&path, LangId::Python, &config);
1574        if ruff_format_available(config.project_root.as_deref()) {
1575            let (cmd, args) = result.unwrap();
1576            assert_eq!(cmd, "ruff");
1577            assert!(args.contains(&"format".to_string()));
1578        } else {
1579            assert!(result.is_none());
1580        }
1581    }
1582
1583    #[test]
1584    fn detect_formatter_no_config_returns_none() {
1585        let path = Path::new("test.ts");
1586        let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1587        assert!(
1588            result.is_none(),
1589            "expected no formatter without project config"
1590        );
1591    }
1592
1593    #[test]
1594    fn detect_formatter_explicit_override() {
1595        // Create a temp dir with a fake node_modules/.bin/biome so resolve_tool finds it
1596        let dir = tempfile::tempdir().unwrap();
1597        let bin_dir = dir.path().join("node_modules").join(".bin");
1598        fs::create_dir_all(&bin_dir).unwrap();
1599        #[cfg(unix)]
1600        {
1601            use std::os::unix::fs::PermissionsExt;
1602            let fake = bin_dir.join("biome");
1603            fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
1604            fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
1605        }
1606        #[cfg(not(unix))]
1607        {
1608            fs::write(bin_dir.join("biome.cmd"), "@echo 1.0.0").unwrap();
1609        }
1610
1611        let path = Path::new("test.ts");
1612        let mut config = Config {
1613            project_root: Some(dir.path().to_path_buf()),
1614            ..Config::default()
1615        };
1616        config
1617            .formatter
1618            .insert("typescript".to_string(), "biome".to_string());
1619        let result = detect_formatter(path, LangId::TypeScript, &config);
1620        let (cmd, args) = result.unwrap();
1621        assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
1622        assert!(args.contains(&"format".to_string()));
1623        assert!(args.contains(&"--write".to_string()));
1624    }
1625
1626    #[test]
1627    fn auto_format_happy_path_rustfmt() {
1628        if resolve_tool("rustfmt", None).is_none() {
1629            log::warn!("skipping: rustfmt not available");
1630            return;
1631        }
1632
1633        let dir = tempfile::tempdir().unwrap();
1634        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1635        let path = dir.path().join("test.rs");
1636
1637        let mut f = fs::File::create(&path).unwrap();
1638        writeln!(f, "fn    main()   {{  println!(\"hello\");  }}").unwrap();
1639        drop(f);
1640
1641        let config = Config {
1642            project_root: Some(dir.path().to_path_buf()),
1643            ..Config::default()
1644        };
1645        let (formatted, reason) = auto_format(&path, &config);
1646        assert!(formatted, "expected formatting to succeed");
1647        assert!(reason.is_none());
1648
1649        let content = fs::read_to_string(&path).unwrap();
1650        assert!(
1651            !content.contains("fn    main"),
1652            "expected rustfmt to fix spacing"
1653        );
1654    }
1655
1656    #[test]
1657    fn parse_tsc_output_basic() {
1658        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";
1659        let file = Path::new("src/app.ts");
1660        let errors = parse_tsc_output(stdout, "", file);
1661        assert_eq!(errors.len(), 2);
1662        assert_eq!(errors[0].line, 10);
1663        assert_eq!(errors[0].column, 5);
1664        assert_eq!(errors[0].severity, "error");
1665        assert!(errors[0].message.contains("TS2322"));
1666        assert_eq!(errors[1].line, 20);
1667    }
1668
1669    #[test]
1670    fn parse_tsc_output_filters_other_files() {
1671        let stdout =
1672            "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
1673        let file = Path::new("src/app.ts");
1674        let errors = parse_tsc_output(stdout, "", file);
1675        assert_eq!(errors.len(), 1);
1676        assert_eq!(errors[0].line, 5);
1677    }
1678
1679    #[test]
1680    fn parse_cargo_output_basic() {
1681        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}]}}"#;
1682        let file = Path::new("src/main.rs");
1683        let errors = parse_cargo_output(json_line, "", file);
1684        assert_eq!(errors.len(), 1);
1685        assert_eq!(errors[0].line, 10);
1686        assert_eq!(errors[0].column, 5);
1687        assert_eq!(errors[0].severity, "error");
1688        assert!(errors[0].message.contains("mismatched types"));
1689    }
1690
1691    #[test]
1692    fn parse_cargo_output_skips_notes() {
1693        // Notes and help messages should be filtered out
1694        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}]}}"#;
1695        let file = Path::new("src/main.rs");
1696        let errors = parse_cargo_output(json_line, "", file);
1697        assert_eq!(errors.len(), 0);
1698    }
1699
1700    #[test]
1701    fn parse_cargo_output_filters_other_files() {
1702        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}]}}"#;
1703        let file = Path::new("src/main.rs");
1704        let errors = parse_cargo_output(json_line, "", file);
1705        assert_eq!(errors.len(), 0);
1706    }
1707
1708    #[test]
1709    fn parse_go_vet_output_basic() {
1710        let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
1711        let file = Path::new("main.go");
1712        let errors = parse_go_vet_output(stderr, file);
1713        assert_eq!(errors.len(), 2);
1714        assert_eq!(errors[0].line, 10);
1715        assert_eq!(errors[0].column, 5);
1716        assert!(errors[0].message.contains("unreachable code"));
1717        assert_eq!(errors[1].line, 20);
1718        assert_eq!(errors[1].column, 0);
1719    }
1720
1721    #[test]
1722    fn parse_pyright_output_basic() {
1723        let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
1724        let file = Path::new("test.py");
1725        let errors = parse_pyright_output(stdout, file);
1726        assert_eq!(errors.len(), 1);
1727        assert_eq!(errors[0].line, 5); // 0-indexed → 1-indexed
1728        assert_eq!(errors[0].column, 10);
1729        assert_eq!(errors[0].severity, "error");
1730        assert!(errors[0].message.contains("Type error here"));
1731    }
1732
1733    #[test]
1734    fn validate_full_unsupported_language() {
1735        let dir = tempfile::tempdir().unwrap();
1736        let path = dir.path().join("file.txt");
1737        fs::write(&path, "hello").unwrap();
1738
1739        let config = Config::default();
1740        let (errors, reason) = validate_full(&path, &config);
1741        assert!(errors.is_empty());
1742        assert_eq!(reason.as_deref(), Some("unsupported_language"));
1743    }
1744
1745    #[test]
1746    fn detect_type_checker_rust() {
1747        let dir = tempfile::tempdir().unwrap();
1748        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1749        let path = dir.path().join("src/main.rs");
1750        let config = Config {
1751            project_root: Some(dir.path().to_path_buf()),
1752            ..Config::default()
1753        };
1754        let result = detect_type_checker(&path, LangId::Rust, &config);
1755        if resolve_tool("cargo", config.project_root.as_deref()).is_some() {
1756            let (cmd, args) = result.unwrap();
1757            assert_eq!(cmd, "cargo");
1758            assert!(args.contains(&"check".to_string()));
1759        } else {
1760            assert!(result.is_none());
1761        }
1762    }
1763
1764    #[test]
1765    fn detect_type_checker_go() {
1766        let dir = tempfile::tempdir().unwrap();
1767        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1768        let path = dir.path().join("main.go");
1769        let config = Config {
1770            project_root: Some(dir.path().to_path_buf()),
1771            ..Config::default()
1772        };
1773        let result = detect_type_checker(&path, LangId::Go, &config);
1774        if resolve_tool("go", config.project_root.as_deref()).is_some() {
1775            let (cmd, _args) = result.unwrap();
1776            // Could be staticcheck or go vet depending on what's installed
1777            assert!(cmd == "go" || cmd == "staticcheck");
1778        } else {
1779            assert!(result.is_none());
1780        }
1781    }
1782    #[test]
1783    fn run_external_tool_capture_nonzero_not_error() {
1784        // `false` exits with code 1 — capture should still return Ok
1785        let result = run_external_tool_capture("false", &[], None, 5);
1786        assert!(result.is_ok(), "capture should not error on non-zero exit");
1787        assert_eq!(result.unwrap().exit_code, 1);
1788    }
1789
1790    #[test]
1791    fn run_external_tool_capture_not_found() {
1792        let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
1793        assert!(result.is_err());
1794        match result.unwrap_err() {
1795            FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
1796            other => panic!("expected NotFound, got: {:?}", other),
1797        }
1798    }
1799}