1use std::io::ErrorKind;
7use std::path::Path;
8use std::process::{Command, Stdio};
9use std::thread;
10use std::time::{Duration, Instant};
11
12use crate::config::Config;
13use crate::parser::{detect_language, LangId};
14
15#[derive(Debug)]
17pub struct ExternalToolResult {
18 pub stdout: String,
19 pub stderr: String,
20 pub exit_code: i32,
21}
22
23#[derive(Debug)]
25pub enum FormatError {
26 NotFound { tool: String },
28 Timeout { tool: String, timeout_secs: u32 },
30 Failed { tool: String, stderr: String },
32 UnsupportedLanguage,
34}
35
36impl std::fmt::Display for FormatError {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 FormatError::NotFound { tool } => write!(f, "formatter not found: {}", tool),
40 FormatError::Timeout { tool, timeout_secs } => {
41 write!(f, "formatter '{}' timed out after {}s", tool, timeout_secs)
42 }
43 FormatError::Failed { tool, stderr } => {
44 write!(f, "formatter '{}' failed: {}", tool, stderr)
45 }
46 FormatError::UnsupportedLanguage => write!(f, "unsupported language for formatting"),
47 }
48 }
49}
50
51pub fn run_external_tool(
57 command: &str,
58 args: &[&str],
59 working_dir: Option<&Path>,
60 timeout_secs: u32,
61) -> Result<ExternalToolResult, FormatError> {
62 let mut cmd = Command::new(command);
63 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
64
65 if let Some(dir) = working_dir {
66 cmd.current_dir(dir);
67 }
68
69 let mut child = match cmd.spawn() {
70 Ok(c) => c,
71 Err(e) if e.kind() == ErrorKind::NotFound => {
72 return Err(FormatError::NotFound {
73 tool: command.to_string(),
74 });
75 }
76 Err(e) => {
77 return Err(FormatError::Failed {
78 tool: command.to_string(),
79 stderr: e.to_string(),
80 });
81 }
82 };
83
84 let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
85
86 loop {
87 match child.try_wait() {
88 Ok(Some(status)) => {
89 let stdout = child
90 .stdout
91 .take()
92 .map(|s| std::io::read_to_string(s).unwrap_or_default())
93 .unwrap_or_default();
94 let stderr = child
95 .stderr
96 .take()
97 .map(|s| std::io::read_to_string(s).unwrap_or_default())
98 .unwrap_or_default();
99
100 let exit_code = status.code().unwrap_or(-1);
101 if exit_code != 0 {
102 return Err(FormatError::Failed {
103 tool: command.to_string(),
104 stderr,
105 });
106 }
107
108 return Ok(ExternalToolResult {
109 stdout,
110 stderr,
111 exit_code,
112 });
113 }
114 Ok(None) => {
115 if Instant::now() >= deadline {
117 let _ = child.kill();
119 let _ = child.wait();
120 return Err(FormatError::Timeout {
121 tool: command.to_string(),
122 timeout_secs,
123 });
124 }
125 thread::sleep(Duration::from_millis(50));
126 }
127 Err(e) => {
128 return Err(FormatError::Failed {
129 tool: command.to_string(),
130 stderr: format!("try_wait error: {}", e),
131 });
132 }
133 }
134 }
135}
136
137fn tool_available(command: &str) -> bool {
141 match Command::new(command)
142 .arg("--version")
143 .stdout(Stdio::null())
144 .stderr(Stdio::null())
145 .spawn()
146 {
147 Ok(mut child) => {
148 let _ = child.wait();
149 true
150 }
151 Err(_) => false,
152 }
153}
154
155fn ruff_format_available() -> bool {
162 let output = match Command::new("ruff")
163 .arg("--version")
164 .stdout(Stdio::piped())
165 .stderr(Stdio::null())
166 .output()
167 {
168 Ok(o) => o,
169 Err(_) => return false,
170 };
171
172 let version_str = String::from_utf8_lossy(&output.stdout);
173 let version_part = version_str
175 .trim()
176 .strip_prefix("ruff ")
177 .unwrap_or(version_str.trim());
178
179 let parts: Vec<&str> = version_part.split('.').collect();
180 if parts.len() < 3 {
181 return false;
182 }
183
184 let major: u32 = match parts[0].parse() {
185 Ok(v) => v,
186 Err(_) => return false,
187 };
188 let minor: u32 = match parts[1].parse() {
189 Ok(v) => v,
190 Err(_) => return false,
191 };
192 let patch: u32 = match parts[2].parse() {
193 Ok(v) => v,
194 Err(_) => return false,
195 };
196
197 (major, minor, patch) >= (0, 1, 2)
199}
200
201pub fn detect_formatter(
211 path: &Path,
212 lang: LangId,
213 config: &Config,
214) -> Option<(String, Vec<String>)> {
215 let file_str = path.to_string_lossy().to_string();
216
217 let lang_key = match lang {
219 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
220 LangId::Python => "python",
221 LangId::Rust => "rust",
222 LangId::Go => "go",
223 LangId::Markdown => "markdown",
224 };
225 if let Some(preferred) = config.formatter.get(lang_key) {
226 return resolve_explicit_formatter(preferred, &file_str, lang);
227 }
228
229 let project_root = config.project_root.as_deref();
232
233 match lang {
234 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
235 if has_project_config(project_root, &["biome.json", "biome.jsonc"])
237 && tool_available("biome")
238 {
239 return Some((
240 "biome".to_string(),
241 vec!["format".to_string(), "--write".to_string(), file_str],
242 ));
243 }
244 if has_project_config(
246 project_root,
247 &[
248 ".prettierrc",
249 ".prettierrc.json",
250 ".prettierrc.yml",
251 ".prettierrc.yaml",
252 ".prettierrc.js",
253 ".prettierrc.cjs",
254 ".prettierrc.mjs",
255 ".prettierrc.toml",
256 "prettier.config.js",
257 "prettier.config.cjs",
258 "prettier.config.mjs",
259 ],
260 ) && tool_available("prettier")
261 {
262 return Some((
263 "prettier".to_string(),
264 vec!["--write".to_string(), file_str],
265 ));
266 }
267 if has_project_config(project_root, &["deno.json", "deno.jsonc"])
269 && tool_available("deno")
270 {
271 return Some(("deno".to_string(), vec!["fmt".to_string(), file_str]));
272 }
273 None
275 }
276 LangId::Python => {
277 if (has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
279 || has_pyproject_tool(project_root, "ruff"))
280 && ruff_format_available()
281 {
282 return Some(("ruff".to_string(), vec!["format".to_string(), file_str]));
283 }
284 if has_pyproject_tool(project_root, "black") && tool_available("black") {
286 return Some(("black".to_string(), vec![file_str]));
287 }
288 None
290 }
291 LangId::Rust => {
292 if has_project_config(project_root, &["Cargo.toml"]) && tool_available("rustfmt") {
294 Some(("rustfmt".to_string(), vec![file_str]))
295 } else {
296 None
297 }
298 }
299 LangId::Go => {
300 if has_project_config(project_root, &["go.mod"]) {
302 if tool_available("goimports") {
303 Some(("goimports".to_string(), vec!["-w".to_string(), file_str]))
304 } else if tool_available("gofmt") {
305 Some(("gofmt".to_string(), vec!["-w".to_string(), file_str]))
306 } else {
307 None
308 }
309 } else {
310 None
311 }
312 }
313 LangId::Markdown => None,
314 }
315}
316
317fn resolve_explicit_formatter(
319 name: &str,
320 file_str: &str,
321 lang: LangId,
322) -> Option<(String, Vec<String>)> {
323 match name {
324 "biome" => Some((
325 "biome".to_string(),
326 vec![
327 "format".to_string(),
328 "--write".to_string(),
329 file_str.to_string(),
330 ],
331 )),
332 "prettier" => Some((
333 "prettier".to_string(),
334 vec!["--write".to_string(), file_str.to_string()],
335 )),
336 "deno" => Some((
337 "deno".to_string(),
338 vec!["fmt".to_string(), file_str.to_string()],
339 )),
340 "ruff" => Some((
341 "ruff".to_string(),
342 vec!["format".to_string(), file_str.to_string()],
343 )),
344 "black" => Some(("black".to_string(), vec![file_str.to_string()])),
345 "rustfmt" => Some(("rustfmt".to_string(), vec![file_str.to_string()])),
346 "goimports" => Some((
347 "goimports".to_string(),
348 vec!["-w".to_string(), file_str.to_string()],
349 )),
350 "gofmt" => Some((
351 "gofmt".to_string(),
352 vec!["-w".to_string(), file_str.to_string()],
353 )),
354 "none" | "off" | "false" => None,
355 _ => {
356 eprintln!(
357 "[aft] format: unknown preferred_formatter '{}' for {:?}, falling back to auto",
358 name, lang
359 );
360 None
361 }
362 }
363}
364
365fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
367 let root = match project_root {
368 Some(r) => r,
369 None => return false,
370 };
371 filenames.iter().any(|f| root.join(f).exists())
372}
373
374fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
376 let root = match project_root {
377 Some(r) => r,
378 None => return false,
379 };
380 let pyproject = root.join("pyproject.toml");
381 if !pyproject.exists() {
382 return false;
383 }
384 match std::fs::read_to_string(&pyproject) {
385 Ok(content) => {
386 let pattern = format!("[tool.{}]", tool_name);
387 content.contains(&pattern)
388 }
389 Err(_) => false,
390 }
391}
392
393pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
401 if !config.format_on_edit {
403 return (false, Some("disabled".to_string()));
404 }
405
406 let lang = match detect_language(path) {
407 Some(l) => l,
408 None => {
409 eprintln!(
410 "[aft] format: {} (skipped: unsupported_language)",
411 path.display()
412 );
413 return (false, Some("unsupported_language".to_string()));
414 }
415 };
416
417 let (cmd, args) = match detect_formatter(path, lang, config) {
418 Some(pair) => pair,
419 None => {
420 eprintln!("[aft] format: {} (skipped: not_found)", path.display());
421 return (false, Some("not_found".to_string()));
422 }
423 };
424
425 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
426
427 match run_external_tool(&cmd, &arg_refs, None, config.formatter_timeout_secs) {
428 Ok(_) => {
429 eprintln!("[aft] format: {} ({})", path.display(), cmd);
430 (true, None)
431 }
432 Err(FormatError::Timeout { .. }) => {
433 eprintln!("[aft] format: {} (skipped: timeout)", path.display());
434 (false, Some("timeout".to_string()))
435 }
436 Err(FormatError::NotFound { .. }) => {
437 eprintln!("[aft] format: {} (skipped: not_found)", path.display());
438 (false, Some("not_found".to_string()))
439 }
440 Err(FormatError::Failed { stderr, .. }) => {
441 eprintln!(
442 "[aft] format: {} (skipped: error: {})",
443 path.display(),
444 stderr.lines().next().unwrap_or("unknown")
445 );
446 (false, Some("error".to_string()))
447 }
448 Err(FormatError::UnsupportedLanguage) => {
449 eprintln!(
450 "[aft] format: {} (skipped: unsupported_language)",
451 path.display()
452 );
453 (false, Some("unsupported_language".to_string()))
454 }
455 }
456}
457
458pub fn run_external_tool_capture(
465 command: &str,
466 args: &[&str],
467 working_dir: Option<&Path>,
468 timeout_secs: u32,
469) -> Result<ExternalToolResult, FormatError> {
470 let mut cmd = Command::new(command);
471 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
472
473 if let Some(dir) = working_dir {
474 cmd.current_dir(dir);
475 }
476
477 let mut child = match cmd.spawn() {
478 Ok(c) => c,
479 Err(e) if e.kind() == ErrorKind::NotFound => {
480 return Err(FormatError::NotFound {
481 tool: command.to_string(),
482 });
483 }
484 Err(e) => {
485 return Err(FormatError::Failed {
486 tool: command.to_string(),
487 stderr: e.to_string(),
488 });
489 }
490 };
491
492 let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
493
494 loop {
495 match child.try_wait() {
496 Ok(Some(status)) => {
497 let stdout = child
498 .stdout
499 .take()
500 .map(|s| std::io::read_to_string(s).unwrap_or_default())
501 .unwrap_or_default();
502 let stderr = child
503 .stderr
504 .take()
505 .map(|s| std::io::read_to_string(s).unwrap_or_default())
506 .unwrap_or_default();
507
508 return Ok(ExternalToolResult {
509 stdout,
510 stderr,
511 exit_code: status.code().unwrap_or(-1),
512 });
513 }
514 Ok(None) => {
515 if Instant::now() >= deadline {
516 let _ = child.kill();
517 let _ = child.wait();
518 return Err(FormatError::Timeout {
519 tool: command.to_string(),
520 timeout_secs,
521 });
522 }
523 thread::sleep(Duration::from_millis(50));
524 }
525 Err(e) => {
526 return Err(FormatError::Failed {
527 tool: command.to_string(),
528 stderr: format!("try_wait error: {}", e),
529 });
530 }
531 }
532 }
533}
534
535#[derive(Debug, Clone, serde::Serialize)]
541pub struct ValidationError {
542 pub line: u32,
543 pub column: u32,
544 pub message: String,
545 pub severity: String,
546}
547
548pub fn detect_type_checker(
559 path: &Path,
560 lang: LangId,
561 config: &Config,
562) -> Option<(String, Vec<String>)> {
563 let file_str = path.to_string_lossy().to_string();
564 let project_root = config.project_root.as_deref();
565
566 let lang_key = match lang {
568 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
569 LangId::Python => "python",
570 LangId::Rust => "rust",
571 LangId::Go => "go",
572 LangId::Markdown => "markdown",
573 };
574 if let Some(preferred) = config.checker.get(lang_key) {
575 return resolve_explicit_checker(preferred, &file_str, lang);
576 }
577
578 match lang {
579 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
580 if has_project_config(project_root, &["biome.json", "biome.jsonc"])
582 && tool_available("biome")
583 {
584 return Some(("biome".to_string(), vec!["check".to_string(), file_str]));
585 }
586 if has_project_config(project_root, &["tsconfig.json"]) {
588 if tool_available("npx") {
589 return Some((
590 "npx".to_string(),
591 vec![
592 "tsc".to_string(),
593 "--noEmit".to_string(),
594 "--pretty".to_string(),
595 "false".to_string(),
596 ],
597 ));
598 } else if tool_available("tsc") {
599 return Some((
600 "tsc".to_string(),
601 vec![
602 "--noEmit".to_string(),
603 "--pretty".to_string(),
604 "false".to_string(),
605 ],
606 ));
607 }
608 }
609 None
610 }
611 LangId::Python => {
612 if (has_project_config(project_root, &["pyrightconfig.json"])
614 || has_pyproject_tool(project_root, "pyright"))
615 && tool_available("pyright")
616 {
617 return Some((
618 "pyright".to_string(),
619 vec!["--outputjson".to_string(), file_str],
620 ));
621 }
622 if (has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
624 || has_pyproject_tool(project_root, "ruff"))
625 && ruff_format_available()
626 {
627 return Some((
628 "ruff".to_string(),
629 vec![
630 "check".to_string(),
631 "--output-format=json".to_string(),
632 file_str,
633 ],
634 ));
635 }
636 None
637 }
638 LangId::Rust => {
639 if has_project_config(project_root, &["Cargo.toml"]) && tool_available("cargo") {
641 Some((
642 "cargo".to_string(),
643 vec!["check".to_string(), "--message-format=json".to_string()],
644 ))
645 } else {
646 None
647 }
648 }
649 LangId::Go => {
650 if has_project_config(project_root, &["go.mod"]) {
652 if tool_available("staticcheck") {
653 Some(("staticcheck".to_string(), vec![file_str]))
654 } else if tool_available("go") {
655 Some(("go".to_string(), vec!["vet".to_string(), file_str]))
656 } else {
657 None
658 }
659 } else {
660 None
661 }
662 }
663 LangId::Markdown => None,
664 }
665}
666
667fn resolve_explicit_checker(
669 name: &str,
670 file_str: &str,
671 _lang: LangId,
672) -> Option<(String, Vec<String>)> {
673 match name {
674 "tsc" => Some((
675 "npx".to_string(),
676 vec![
677 "tsc".to_string(),
678 "--noEmit".to_string(),
679 "--pretty".to_string(),
680 "false".to_string(),
681 ],
682 )),
683 "biome" => Some((
684 "biome".to_string(),
685 vec!["check".to_string(), file_str.to_string()],
686 )),
687 "pyright" => Some((
688 "pyright".to_string(),
689 vec!["--outputjson".to_string(), file_str.to_string()],
690 )),
691 "ruff" => Some((
692 "ruff".to_string(),
693 vec![
694 "check".to_string(),
695 "--output-format=json".to_string(),
696 file_str.to_string(),
697 ],
698 )),
699 "cargo" => Some((
700 "cargo".to_string(),
701 vec!["check".to_string(), "--message-format=json".to_string()],
702 )),
703 "go" => Some((
704 "go".to_string(),
705 vec!["vet".to_string(), file_str.to_string()],
706 )),
707 "staticcheck" => Some(("staticcheck".to_string(), vec![file_str.to_string()])),
708 "none" | "off" | "false" => None,
709 _ => {
710 eprintln!(
711 "[aft] validate: unknown preferred_checker '{}', falling back to auto",
712 name
713 );
714 None
715 }
716 }
717}
718
719pub fn parse_checker_output(
724 stdout: &str,
725 stderr: &str,
726 file: &Path,
727 checker: &str,
728) -> Vec<ValidationError> {
729 match checker {
730 "npx" | "tsc" => parse_tsc_output(stdout, stderr, file),
731 "pyright" => parse_pyright_output(stdout, file),
732 "cargo" => parse_cargo_output(stdout, stderr, file),
733 "go" => parse_go_vet_output(stderr, file),
734 _ => Vec::new(),
735 }
736}
737
738fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
740 let mut errors = Vec::new();
741 let file_str = file.to_string_lossy();
742 let combined = format!("{}{}", stdout, stderr);
744 for line in combined.lines() {
745 if let Some((loc, rest)) = line.split_once("): ") {
748 let file_part = loc.split('(').next().unwrap_or("");
750 if !file_str.ends_with(file_part)
751 && !file_part.ends_with(&*file_str)
752 && file_part != &*file_str
753 {
754 continue;
755 }
756
757 let coords = loc.split('(').last().unwrap_or("");
759 let parts: Vec<&str> = coords.split(',').collect();
760 let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
761 let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
762
763 let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
765 ("error".to_string(), msg.to_string())
766 } else if let Some(msg) = rest.strip_prefix("warning ") {
767 ("warning".to_string(), msg.to_string())
768 } else {
769 ("error".to_string(), rest.to_string())
770 };
771
772 errors.push(ValidationError {
773 line: line_num,
774 column: col_num,
775 message,
776 severity,
777 });
778 }
779 }
780 errors
781}
782
783fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
785 let mut errors = Vec::new();
786 let file_str = file.to_string_lossy();
787
788 if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
790 if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
791 for diag in diags {
792 let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
794 if !diag_file.is_empty()
795 && !file_str.ends_with(diag_file)
796 && !diag_file.ends_with(&*file_str)
797 && diag_file != &*file_str
798 {
799 continue;
800 }
801
802 let line_num = diag
803 .get("range")
804 .and_then(|r| r.get("start"))
805 .and_then(|s| s.get("line"))
806 .and_then(|l| l.as_u64())
807 .unwrap_or(0) as u32;
808 let col_num = diag
809 .get("range")
810 .and_then(|r| r.get("start"))
811 .and_then(|s| s.get("character"))
812 .and_then(|c| c.as_u64())
813 .unwrap_or(0) as u32;
814 let message = diag
815 .get("message")
816 .and_then(|m| m.as_str())
817 .unwrap_or("unknown error")
818 .to_string();
819 let severity = diag
820 .get("severity")
821 .and_then(|s| s.as_str())
822 .unwrap_or("error")
823 .to_lowercase();
824
825 errors.push(ValidationError {
826 line: line_num + 1, column: col_num,
828 message,
829 severity,
830 });
831 }
832 }
833 }
834 errors
835}
836
837fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
839 let mut errors = Vec::new();
840 let file_str = file.to_string_lossy();
841
842 for line in stdout.lines() {
843 if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
844 if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
845 continue;
846 }
847 let message_obj = match msg.get("message") {
848 Some(m) => m,
849 None => continue,
850 };
851
852 let level = message_obj
853 .get("level")
854 .and_then(|l| l.as_str())
855 .unwrap_or("error");
856
857 if level != "error" && level != "warning" {
859 continue;
860 }
861
862 let text = message_obj
863 .get("message")
864 .and_then(|m| m.as_str())
865 .unwrap_or("unknown error")
866 .to_string();
867
868 if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
870 for span in spans {
871 let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
872 let is_primary = span
873 .get("is_primary")
874 .and_then(|p| p.as_bool())
875 .unwrap_or(false);
876
877 if !is_primary {
878 continue;
879 }
880
881 if !file_str.ends_with(span_file)
883 && !span_file.ends_with(&*file_str)
884 && span_file != &*file_str
885 {
886 continue;
887 }
888
889 let line_num =
890 span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
891 let col_num = span
892 .get("column_start")
893 .and_then(|c| c.as_u64())
894 .unwrap_or(0) as u32;
895
896 errors.push(ValidationError {
897 line: line_num,
898 column: col_num,
899 message: text.clone(),
900 severity: level.to_string(),
901 });
902 }
903 }
904 }
905 }
906 errors
907}
908
909fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
911 let mut errors = Vec::new();
912 let file_str = file.to_string_lossy();
913
914 for line in stderr.lines() {
915 let parts: Vec<&str> = line.splitn(4, ':').collect();
917 if parts.len() < 3 {
918 continue;
919 }
920
921 let err_file = parts[0].trim();
922 if !file_str.ends_with(err_file)
923 && !err_file.ends_with(&*file_str)
924 && err_file != &*file_str
925 {
926 continue;
927 }
928
929 let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
930 let (col_num, message) = if parts.len() >= 4 {
931 if let Ok(col) = parts[2].trim().parse::<u32>() {
932 (col, parts[3].trim().to_string())
933 } else {
934 (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
936 }
937 } else {
938 (0, parts[2].trim().to_string())
939 };
940
941 errors.push(ValidationError {
942 line: line_num,
943 column: col_num,
944 message,
945 severity: "error".to_string(),
946 });
947 }
948 errors
949}
950
951pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
959 let lang = match detect_language(path) {
960 Some(l) => l,
961 None => {
962 eprintln!(
963 "[aft] validate: {} (skipped: unsupported_language)",
964 path.display()
965 );
966 return (Vec::new(), Some("unsupported_language".to_string()));
967 }
968 };
969
970 let (cmd, args) = match detect_type_checker(path, lang, config) {
971 Some(pair) => pair,
972 None => {
973 eprintln!("[aft] validate: {} (skipped: not_found)", path.display());
974 return (Vec::new(), Some("not_found".to_string()));
975 }
976 };
977
978 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
979
980 let working_dir = path.parent();
982
983 match run_external_tool_capture(
984 &cmd,
985 &arg_refs,
986 working_dir,
987 config.type_checker_timeout_secs,
988 ) {
989 Ok(result) => {
990 let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
991 eprintln!(
992 "[aft] validate: {} ({}, {} errors)",
993 path.display(),
994 cmd,
995 errors.len()
996 );
997 (errors, None)
998 }
999 Err(FormatError::Timeout { .. }) => {
1000 eprintln!("[aft] validate: {} (skipped: timeout)", path.display());
1001 (Vec::new(), Some("timeout".to_string()))
1002 }
1003 Err(FormatError::NotFound { .. }) => {
1004 eprintln!("[aft] validate: {} (skipped: not_found)", path.display());
1005 (Vec::new(), Some("not_found".to_string()))
1006 }
1007 Err(FormatError::Failed { stderr, .. }) => {
1008 eprintln!(
1009 "[aft] validate: {} (skipped: error: {})",
1010 path.display(),
1011 stderr.lines().next().unwrap_or("unknown")
1012 );
1013 (Vec::new(), Some("error".to_string()))
1014 }
1015 Err(FormatError::UnsupportedLanguage) => {
1016 eprintln!(
1017 "[aft] validate: {} (skipped: unsupported_language)",
1018 path.display()
1019 );
1020 (Vec::new(), Some("unsupported_language".to_string()))
1021 }
1022 }
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027 use super::*;
1028 use std::fs;
1029 use std::io::Write;
1030
1031 #[test]
1032 fn run_external_tool_not_found() {
1033 let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1034 assert!(result.is_err());
1035 match result.unwrap_err() {
1036 FormatError::NotFound { tool } => {
1037 assert_eq!(tool, "__nonexistent_tool_xyz__");
1038 }
1039 other => panic!("expected NotFound, got: {:?}", other),
1040 }
1041 }
1042
1043 #[test]
1044 fn run_external_tool_timeout_kills_subprocess() {
1045 let result = run_external_tool("sleep", &["60"], None, 1);
1047 assert!(result.is_err());
1048 match result.unwrap_err() {
1049 FormatError::Timeout { tool, timeout_secs } => {
1050 assert_eq!(tool, "sleep");
1051 assert_eq!(timeout_secs, 1);
1052 }
1053 other => panic!("expected Timeout, got: {:?}", other),
1054 }
1055 }
1056
1057 #[test]
1058 fn run_external_tool_success() {
1059 let result = run_external_tool("echo", &["hello"], None, 5);
1060 assert!(result.is_ok());
1061 let res = result.unwrap();
1062 assert_eq!(res.exit_code, 0);
1063 assert!(res.stdout.contains("hello"));
1064 }
1065
1066 #[test]
1067 fn run_external_tool_nonzero_exit() {
1068 let result = run_external_tool("false", &[], None, 5);
1070 assert!(result.is_err());
1071 match result.unwrap_err() {
1072 FormatError::Failed { tool, .. } => {
1073 assert_eq!(tool, "false");
1074 }
1075 other => panic!("expected Failed, got: {:?}", other),
1076 }
1077 }
1078
1079 #[test]
1080 fn auto_format_unsupported_language() {
1081 let dir = tempfile::tempdir().unwrap();
1082 let path = dir.path().join("file.txt");
1083 fs::write(&path, "hello").unwrap();
1084
1085 let config = Config::default();
1086 let (formatted, reason) = auto_format(&path, &config);
1087 assert!(!formatted);
1088 assert_eq!(reason.as_deref(), Some("unsupported_language"));
1089 }
1090
1091 #[test]
1092 fn detect_formatter_rust_when_rustfmt_available() {
1093 let dir = tempfile::tempdir().unwrap();
1094 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1095 let path = dir.path().join("test.rs");
1096 let config = Config {
1097 project_root: Some(dir.path().to_path_buf()),
1098 ..Config::default()
1099 };
1100 let result = detect_formatter(&path, LangId::Rust, &config);
1101 if tool_available("rustfmt") {
1102 let (cmd, args) = result.unwrap();
1103 assert_eq!(cmd, "rustfmt");
1104 assert!(args.iter().any(|a| a.ends_with("test.rs")));
1105 } else {
1106 assert!(result.is_none());
1107 }
1108 }
1109
1110 #[test]
1111 fn detect_formatter_go_mapping() {
1112 let dir = tempfile::tempdir().unwrap();
1113 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1114 let path = dir.path().join("main.go");
1115 let config = Config {
1116 project_root: Some(dir.path().to_path_buf()),
1117 ..Config::default()
1118 };
1119 let result = detect_formatter(&path, LangId::Go, &config);
1120 if tool_available("goimports") {
1121 let (cmd, args) = result.unwrap();
1122 assert_eq!(cmd, "goimports");
1123 assert!(args.contains(&"-w".to_string()));
1124 } else if tool_available("gofmt") {
1125 let (cmd, args) = result.unwrap();
1126 assert_eq!(cmd, "gofmt");
1127 assert!(args.contains(&"-w".to_string()));
1128 } else {
1129 assert!(result.is_none());
1130 }
1131 }
1132
1133 #[test]
1134 fn detect_formatter_python_mapping() {
1135 let dir = tempfile::tempdir().unwrap();
1136 fs::write(dir.path().join("ruff.toml"), "").unwrap();
1137 let path = dir.path().join("main.py");
1138 let config = Config {
1139 project_root: Some(dir.path().to_path_buf()),
1140 ..Config::default()
1141 };
1142 let result = detect_formatter(&path, LangId::Python, &config);
1143 if ruff_format_available() {
1144 let (cmd, args) = result.unwrap();
1145 assert_eq!(cmd, "ruff");
1146 assert!(args.contains(&"format".to_string()));
1147 } else {
1148 assert!(result.is_none());
1149 }
1150 }
1151
1152 #[test]
1153 fn detect_formatter_no_config_returns_none() {
1154 let path = Path::new("test.ts");
1155 let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1156 assert!(
1157 result.is_none(),
1158 "expected no formatter without project config"
1159 );
1160 }
1161
1162 #[test]
1163 fn detect_formatter_explicit_override() {
1164 let path = Path::new("test.ts");
1165 let mut config = Config::default();
1166 config
1167 .formatter
1168 .insert("typescript".to_string(), "biome".to_string());
1169 let result = detect_formatter(path, LangId::TypeScript, &config);
1170 let (cmd, _) = result.unwrap();
1171 assert_eq!(cmd, "biome");
1172 }
1173
1174 #[test]
1175 fn auto_format_happy_path_rustfmt() {
1176 if !tool_available("rustfmt") {
1177 eprintln!("skipping: rustfmt not available");
1178 return;
1179 }
1180
1181 let dir = tempfile::tempdir().unwrap();
1182 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1183 let path = dir.path().join("test.rs");
1184
1185 let mut f = fs::File::create(&path).unwrap();
1186 writeln!(f, "fn main() {{ println!(\"hello\"); }}").unwrap();
1187 drop(f);
1188
1189 let config = Config {
1190 project_root: Some(dir.path().to_path_buf()),
1191 ..Config::default()
1192 };
1193 let (formatted, reason) = auto_format(&path, &config);
1194 assert!(formatted, "expected formatting to succeed");
1195 assert!(reason.is_none());
1196
1197 let content = fs::read_to_string(&path).unwrap();
1198 assert!(
1199 !content.contains("fn main"),
1200 "expected rustfmt to fix spacing"
1201 );
1202 }
1203
1204 #[test]
1205 fn parse_tsc_output_basic() {
1206 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";
1207 let file = Path::new("src/app.ts");
1208 let errors = parse_tsc_output(stdout, "", file);
1209 assert_eq!(errors.len(), 2);
1210 assert_eq!(errors[0].line, 10);
1211 assert_eq!(errors[0].column, 5);
1212 assert_eq!(errors[0].severity, "error");
1213 assert!(errors[0].message.contains("TS2322"));
1214 assert_eq!(errors[1].line, 20);
1215 }
1216
1217 #[test]
1218 fn parse_tsc_output_filters_other_files() {
1219 let stdout =
1220 "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
1221 let file = Path::new("src/app.ts");
1222 let errors = parse_tsc_output(stdout, "", file);
1223 assert_eq!(errors.len(), 1);
1224 assert_eq!(errors[0].line, 5);
1225 }
1226
1227 #[test]
1228 fn parse_cargo_output_basic() {
1229 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}]}}"#;
1230 let file = Path::new("src/main.rs");
1231 let errors = parse_cargo_output(json_line, "", file);
1232 assert_eq!(errors.len(), 1);
1233 assert_eq!(errors[0].line, 10);
1234 assert_eq!(errors[0].column, 5);
1235 assert_eq!(errors[0].severity, "error");
1236 assert!(errors[0].message.contains("mismatched types"));
1237 }
1238
1239 #[test]
1240 fn parse_cargo_output_skips_notes() {
1241 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}]}}"#;
1243 let file = Path::new("src/main.rs");
1244 let errors = parse_cargo_output(json_line, "", file);
1245 assert_eq!(errors.len(), 0);
1246 }
1247
1248 #[test]
1249 fn parse_cargo_output_filters_other_files() {
1250 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}]}}"#;
1251 let file = Path::new("src/main.rs");
1252 let errors = parse_cargo_output(json_line, "", file);
1253 assert_eq!(errors.len(), 0);
1254 }
1255
1256 #[test]
1257 fn parse_go_vet_output_basic() {
1258 let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
1259 let file = Path::new("main.go");
1260 let errors = parse_go_vet_output(stderr, file);
1261 assert_eq!(errors.len(), 2);
1262 assert_eq!(errors[0].line, 10);
1263 assert_eq!(errors[0].column, 5);
1264 assert!(errors[0].message.contains("unreachable code"));
1265 assert_eq!(errors[1].line, 20);
1266 assert_eq!(errors[1].column, 0);
1267 }
1268
1269 #[test]
1270 fn parse_pyright_output_basic() {
1271 let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
1272 let file = Path::new("test.py");
1273 let errors = parse_pyright_output(stdout, file);
1274 assert_eq!(errors.len(), 1);
1275 assert_eq!(errors[0].line, 5); assert_eq!(errors[0].column, 10);
1277 assert_eq!(errors[0].severity, "error");
1278 assert!(errors[0].message.contains("Type error here"));
1279 }
1280
1281 #[test]
1282 fn validate_full_unsupported_language() {
1283 let dir = tempfile::tempdir().unwrap();
1284 let path = dir.path().join("file.txt");
1285 fs::write(&path, "hello").unwrap();
1286
1287 let config = Config::default();
1288 let (errors, reason) = validate_full(&path, &config);
1289 assert!(errors.is_empty());
1290 assert_eq!(reason.as_deref(), Some("unsupported_language"));
1291 }
1292
1293 #[test]
1294 fn detect_type_checker_rust() {
1295 let dir = tempfile::tempdir().unwrap();
1296 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1297 let path = dir.path().join("src/main.rs");
1298 let config = Config {
1299 project_root: Some(dir.path().to_path_buf()),
1300 ..Config::default()
1301 };
1302 let result = detect_type_checker(&path, LangId::Rust, &config);
1303 if tool_available("cargo") {
1304 let (cmd, args) = result.unwrap();
1305 assert_eq!(cmd, "cargo");
1306 assert!(args.contains(&"check".to_string()));
1307 } else {
1308 assert!(result.is_none());
1309 }
1310 }
1311
1312 #[test]
1313 fn detect_type_checker_go() {
1314 let dir = tempfile::tempdir().unwrap();
1315 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1316 let path = dir.path().join("main.go");
1317 let config = Config {
1318 project_root: Some(dir.path().to_path_buf()),
1319 ..Config::default()
1320 };
1321 let result = detect_type_checker(&path, LangId::Go, &config);
1322 if tool_available("go") {
1323 let (cmd, _args) = result.unwrap();
1324 assert!(cmd == "go" || cmd == "staticcheck");
1326 } else {
1327 assert!(result.is_none());
1328 }
1329 }
1330 #[test]
1331 fn run_external_tool_capture_nonzero_not_error() {
1332 let result = run_external_tool_capture("false", &[], None, 5);
1334 assert!(result.is_ok(), "capture should not error on non-zero exit");
1335 assert_eq!(result.unwrap().exit_code, 1);
1336 }
1337
1338 #[test]
1339 fn run_external_tool_capture_not_found() {
1340 let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
1341 assert!(result.is_err());
1342 match result.unwrap_err() {
1343 FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
1344 other => panic!("expected NotFound, got: {:?}", other),
1345 }
1346 }
1347}