1use std::collections::HashMap;
8use std::fmt;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12#[derive(Debug, Clone)]
14pub struct ToolchainSpec {
15 pub target_id: String,
17 pub display_name: String,
19 pub binary_name: String,
21 pub version_args: Vec<String>,
23 pub compile_command: String,
26 pub compile_args: Vec<String>,
28 pub install_hint: String,
30}
31
32#[derive(Debug, Clone)]
34pub struct DetectedToolchain {
35 pub target_id: String,
37 pub binary_path: PathBuf,
39 pub version: Option<String>,
41}
42
43#[derive(Debug)]
45pub struct CompilationResult {
46 pub target_id: String,
48 pub command: String,
50 pub stdout: String,
52 pub stderr: String,
54 pub success: bool,
56}
57
58#[derive(Debug)]
60pub enum ToolchainError {
61 NotFound {
63 target_id: String,
65 binary_name: String,
67 install_hint: String,
69 },
70 InvocationFailed {
72 target_id: String,
74 command: String,
76 stdout: String,
78 stderr: String,
80 exit_code: Option<i32>,
82 },
83 Io(std::io::Error),
85}
86
87impl fmt::Display for ToolchainError {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 match self {
90 ToolchainError::NotFound {
91 target_id,
92 binary_name,
93 install_hint,
94 } => {
95 write!(
96 f,
97 "Toolchain not found for target '{target_id}': \
98 '{binary_name}' is not installed or not on PATH.\n\
99 To install: {install_hint}"
100 )
101 }
102 ToolchainError::InvocationFailed {
103 target_id,
104 command,
105 stdout,
106 stderr,
107 exit_code,
108 } => {
109 let diagnostic = if !stderr.is_empty() {
110 stderr
111 } else {
112 stdout
113 };
114 write!(
115 f,
116 "Compilation failed for target '{target_id}'.\n\
117 Command: {command}\n\
118 Exit code: {}\n\
119 output:\n{diagnostic}",
120 exit_code
121 .map(|c| c.to_string())
122 .unwrap_or_else(|| "signal".to_string())
123 )
124 }
125 ToolchainError::Io(err) => write!(f, "I/O error during toolchain invocation: {err}"),
126 }
127 }
128}
129
130impl std::error::Error for ToolchainError {
131 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
132 match self {
133 ToolchainError::Io(err) => Some(err),
134 _ => None,
135 }
136 }
137}
138
139impl From<std::io::Error> for ToolchainError {
140 fn from(err: std::io::Error) -> Self {
141 ToolchainError::Io(err)
142 }
143}
144
145#[derive(Debug)]
147pub struct ToolchainRegistry {
148 specs: HashMap<String, ToolchainSpec>,
149}
150
151impl ToolchainRegistry {
152 #[must_use]
154 pub fn new() -> Self {
155 Self {
156 specs: HashMap::new(),
157 }
158 }
159
160 #[must_use]
162 pub fn with_builtins() -> Self {
163 let mut registry = Self::new();
164 registry.register(builtin_javascript_spec());
165 registry.register(builtin_typescript_spec());
166 registry.register(builtin_python_spec());
167 registry.register(builtin_rust_spec());
168 registry.register(builtin_go_spec());
169 registry
170 }
171
172 pub fn register(&mut self, spec: ToolchainSpec) {
174 self.specs.insert(spec.target_id.clone(), spec);
175 }
176
177 #[must_use]
179 pub fn get(&self, target_id: &str) -> Option<&ToolchainSpec> {
180 self.specs.get(target_id)
181 }
182
183 #[must_use]
185 pub fn target_ids(&self) -> Vec<&str> {
186 self.specs.keys().map(|s| s.as_str()).collect()
187 }
188
189 pub fn detect(&self, target_id: &str) -> Result<DetectedToolchain, ToolchainError> {
193 let spec = self
194 .specs
195 .get(target_id)
196 .ok_or_else(|| ToolchainError::NotFound {
197 target_id: target_id.to_string(),
198 binary_name: target_id.to_string(),
199 install_hint: format!("No toolchain registered for target '{target_id}'"),
200 })?;
201
202 detect_toolchain(spec)
203 }
204
205 #[must_use]
207 pub fn detect_all(&self) -> ToolchainReport {
208 let mut found = Vec::new();
209 let mut missing = Vec::new();
210
211 for (target_id, spec) in &self.specs {
212 match detect_toolchain(spec) {
213 Ok(detected) => found.push(detected),
214 Err(err) => missing.push((target_id.clone(), err)),
215 }
216 }
217
218 ToolchainReport { found, missing }
219 }
220
221 pub fn invoke(
225 &self,
226 target_id: &str,
227 source_path: &Path,
228 source_only: bool,
229 ) -> Result<CompilationResult, ToolchainError> {
230 if source_only {
231 return Ok(CompilationResult {
232 target_id: target_id.to_string(),
233 command: "(source-only, compilation skipped)".to_string(),
234 stdout: String::new(),
235 stderr: String::new(),
236 success: true,
237 });
238 }
239
240 let spec = self
241 .specs
242 .get(target_id)
243 .ok_or_else(|| ToolchainError::NotFound {
244 target_id: target_id.to_string(),
245 binary_name: target_id.to_string(),
246 install_hint: format!("No toolchain registered for target '{target_id}'"),
247 })?;
248
249 detect_toolchain(spec)?;
251
252 invoke_compile(spec, source_path)
254 }
255}
256
257impl Default for ToolchainRegistry {
258 fn default() -> Self {
259 Self::with_builtins()
260 }
261}
262
263#[derive(Debug)]
265pub struct ToolchainReport {
266 pub found: Vec<DetectedToolchain>,
268 pub missing: Vec<(String, ToolchainError)>,
270}
271
272impl ToolchainReport {
273 #[must_use]
275 pub fn all_found(&self) -> bool {
276 self.missing.is_empty()
277 }
278}
279
280fn builtin_javascript_spec() -> ToolchainSpec {
285 ToolchainSpec {
286 target_id: "js".to_string(),
287 display_name: "Node.js".to_string(),
288 binary_name: "node".to_string(),
289 version_args: vec!["--version".to_string()],
290 compile_command: "node".to_string(),
291 compile_args: vec!["--check".to_string()],
292 install_hint: "Install Node.js from https://nodejs.org/ or via your package manager \
293 (e.g., `brew install node`, `apt install nodejs`)"
294 .to_string(),
295 }
296}
297
298fn builtin_typescript_spec() -> ToolchainSpec {
299 ToolchainSpec {
300 target_id: "ts".to_string(),
301 display_name: "TypeScript compiler".to_string(),
302 binary_name: "tsc".to_string(),
303 version_args: vec!["--version".to_string()],
304 compile_command: "tsc".to_string(),
305 compile_args: vec!["--noEmit".to_string()],
306 install_hint: "Install TypeScript via npm: `npm install -g typescript`".to_string(),
307 }
308}
309
310fn builtin_python_spec() -> ToolchainSpec {
311 ToolchainSpec {
312 target_id: "python".to_string(),
313 display_name: "Python 3".to_string(),
314 binary_name: "python3".to_string(),
315 version_args: vec!["--version".to_string()],
316 compile_command: "python3".to_string(),
317 compile_args: vec!["-m".to_string(), "py_compile".to_string()],
318 install_hint: "Install Python 3 from https://python.org/ or via your package manager \
319 (e.g., `brew install python3`, `apt install python3`)"
320 .to_string(),
321 }
322}
323
324fn builtin_rust_spec() -> ToolchainSpec {
325 ToolchainSpec {
326 target_id: "rust".to_string(),
327 display_name: "Rust compiler".to_string(),
328 binary_name: "rustc".to_string(),
329 version_args: vec!["--version".to_string()],
330 compile_command: "rustc".to_string(),
331 compile_args: vec!["--edition".to_string(), "2021".to_string()],
332 install_hint: "Install Rust via rustup: https://rustup.rs/".to_string(),
333 }
334}
335
336fn builtin_go_spec() -> ToolchainSpec {
337 ToolchainSpec {
338 target_id: "go".to_string(),
339 display_name: "Go compiler".to_string(),
340 binary_name: "go".to_string(),
341 version_args: vec!["version".to_string()],
342 compile_command: "go".to_string(),
343 compile_args: vec!["vet".to_string()],
344 install_hint: "Install Go from https://go.dev/dl/ or via your package manager \
345 (e.g., `brew install go`, `apt install golang`)"
346 .to_string(),
347 }
348}
349
350fn detect_toolchain(spec: &ToolchainSpec) -> Result<DetectedToolchain, ToolchainError> {
356 let mut cmd = Command::new(&spec.binary_name);
358 for arg in &spec.version_args {
359 cmd.arg(arg);
360 }
361
362 let output = cmd.output().map_err(|e| {
363 if e.kind() == std::io::ErrorKind::NotFound
365 || e.kind() == std::io::ErrorKind::PermissionDenied
366 {
367 ToolchainError::NotFound {
368 target_id: spec.target_id.clone(),
369 binary_name: spec.binary_name.clone(),
370 install_hint: spec.install_hint.clone(),
371 }
372 } else {
373 ToolchainError::Io(e)
374 }
375 })?;
376
377 let version = if output.status.success() {
378 let v = String::from_utf8_lossy(&output.stdout).trim().to_string();
379 if v.is_empty() {
380 None
381 } else {
382 Some(v)
383 }
384 } else {
385 None
386 };
387
388 Ok(DetectedToolchain {
389 target_id: spec.target_id.clone(),
390 binary_path: PathBuf::from(&spec.binary_name),
391 version,
392 })
393}
394
395fn invoke_compile(
397 spec: &ToolchainSpec,
398 source_path: &Path,
399) -> Result<CompilationResult, ToolchainError> {
400 let mut cmd = Command::new(&spec.compile_command);
401 for arg in &spec.compile_args {
402 cmd.arg(arg);
403 }
404 cmd.arg(source_path);
405
406 let full_command = format!(
407 "{} {} {}",
408 spec.compile_command,
409 spec.compile_args.join(" "),
410 source_path.display()
411 );
412
413 let output = cmd.output().map_err(|e| {
414 if e.kind() == std::io::ErrorKind::NotFound
415 || e.kind() == std::io::ErrorKind::PermissionDenied
416 {
417 ToolchainError::NotFound {
418 target_id: spec.target_id.clone(),
419 binary_name: spec.compile_command.clone(),
420 install_hint: spec.install_hint.clone(),
421 }
422 } else {
423 ToolchainError::Io(e)
424 }
425 })?;
426
427 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
428 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
429 let success = output.status.success();
430
431 if !success {
432 return Err(ToolchainError::InvocationFailed {
433 target_id: spec.target_id.clone(),
434 command: full_command,
435 stdout: stdout.clone(),
436 stderr: stderr.clone(),
437 exit_code: output.status.code(),
438 });
439 }
440
441 Ok(CompilationResult {
442 target_id: spec.target_id.clone(),
443 command: full_command,
444 stdout,
445 stderr,
446 success,
447 })
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[test]
455 fn registry_with_builtins_has_all_targets() {
456 let registry = ToolchainRegistry::with_builtins();
457 assert!(registry.get("js").is_some());
458 assert!(registry.get("ts").is_some());
459 assert!(registry.get("python").is_some());
460 assert!(registry.get("rust").is_some());
461 assert!(registry.get("go").is_some());
462 assert_eq!(registry.target_ids().len(), 5);
463 }
464
465 #[test]
466 fn registry_default_equals_builtins() {
467 let registry = ToolchainRegistry::default();
468 assert_eq!(registry.target_ids().len(), 5);
469 }
470
471 #[test]
472 fn registry_custom_spec() {
473 let mut registry = ToolchainRegistry::new();
474 assert!(registry.get("custom").is_none());
475
476 registry.register(ToolchainSpec {
477 target_id: "custom".to_string(),
478 display_name: "Custom Lang".to_string(),
479 binary_name: "customc".to_string(),
480 version_args: vec!["--version".to_string()],
481 compile_command: "customc".to_string(),
482 compile_args: vec!["--check".to_string()],
483 install_hint: "Install custom-lang from example.com".to_string(),
484 });
485
486 assert!(registry.get("custom").is_some());
487 assert_eq!(registry.get("custom").unwrap().display_name, "Custom Lang");
488 }
489
490 #[test]
491 fn unknown_target_returns_not_found() {
492 let registry = ToolchainRegistry::with_builtins();
493 let result = registry.detect("unknown_target_xyz");
494 assert!(result.is_err());
495 match result.unwrap_err() {
496 ToolchainError::NotFound { target_id, .. } => {
497 assert_eq!(target_id, "unknown_target_xyz");
498 }
499 other => panic!("Expected NotFound, got: {other}"),
500 }
501 }
502
503 #[test]
504 fn missing_binary_returns_not_found_error() {
505 let spec = ToolchainSpec {
506 target_id: "fake".to_string(),
507 display_name: "Fake".to_string(),
508 binary_name: "definitely_not_a_real_binary_xyz_123".to_string(),
509 version_args: vec!["--version".to_string()],
510 compile_command: "definitely_not_a_real_binary_xyz_123".to_string(),
511 compile_args: vec![],
512 install_hint: "This is a test".to_string(),
513 };
514
515 let result = detect_toolchain(&spec);
516 assert!(result.is_err());
517 match result.unwrap_err() {
518 ToolchainError::NotFound {
519 target_id,
520 binary_name,
521 install_hint,
522 } => {
523 assert_eq!(target_id, "fake");
524 assert_eq!(binary_name, "definitely_not_a_real_binary_xyz_123");
525 assert_eq!(install_hint, "This is a test");
526 }
527 other => panic!("Expected NotFound, got: {other}"),
528 }
529 }
530
531 #[test]
532 fn not_found_error_display_includes_install_hint() {
533 let err = ToolchainError::NotFound {
534 target_id: "rust".to_string(),
535 binary_name: "rustc".to_string(),
536 install_hint: "Install via rustup".to_string(),
537 };
538 let msg = err.to_string();
539 assert!(msg.contains("rust"));
540 assert!(msg.contains("rustc"));
541 assert!(msg.contains("Install via rustup"));
542 }
543
544 #[test]
545 fn invocation_failed_error_display() {
546 let err = ToolchainError::InvocationFailed {
547 target_id: "js".to_string(),
548 command: "node --check test.js".to_string(),
549 stdout: String::new(),
550 stderr: "SyntaxError: unexpected token".to_string(),
551 exit_code: Some(1),
552 };
553 let msg = err.to_string();
554 assert!(msg.contains("js"));
555 assert!(msg.contains("node --check test.js"));
556 assert!(msg.contains("SyntaxError"));
557 assert!(msg.contains("1"));
558 }
559
560 #[test]
561 fn invocation_failed_prefers_stderr_over_stdout() {
562 let err = ToolchainError::InvocationFailed {
563 target_id: "rust".to_string(),
564 command: "rustc test.rs".to_string(),
565 stdout: "ignored stdout".to_string(),
566 stderr: "real error on stderr".to_string(),
567 exit_code: Some(1),
568 };
569 let msg = err.to_string();
570 assert!(msg.contains("real error on stderr"));
571 assert!(!msg.contains("ignored stdout"));
572 }
573
574 #[test]
575 fn invocation_failed_falls_back_to_stdout() {
576 let err = ToolchainError::InvocationFailed {
577 target_id: "ts".to_string(),
578 command: "tsc --noEmit test.ts".to_string(),
579 stdout: "test.ts(1,1): error TS2304: Cannot find name 'x'.".to_string(),
580 stderr: String::new(),
581 exit_code: Some(2),
582 };
583 let msg = err.to_string();
584 assert!(msg.contains("error TS2304"));
585 assert!(msg.contains("Cannot find name"));
586 }
587
588 #[test]
589 fn source_only_skips_compilation() {
590 let registry = ToolchainRegistry::with_builtins();
591 let result = registry
592 .invoke("js", Path::new("test.js"), true)
593 .expect("source_only should always succeed");
594
595 assert!(result.success);
596 assert!(result.command.contains("source-only"));
597 assert_eq!(result.target_id, "js");
598 }
599
600 #[test]
601 fn source_only_works_for_any_target() {
602 let registry = ToolchainRegistry::with_builtins();
603
604 for target in &["js", "ts", "python", "rust", "go"] {
605 let result = registry
606 .invoke(target, Path::new("test.src"), true)
607 .expect("source_only should succeed for all targets");
608 assert!(result.success);
609 assert_eq!(result.target_id, *target);
610 }
611 }
612
613 #[test]
614 fn invoke_unknown_target_returns_error() {
615 let registry = ToolchainRegistry::with_builtins();
616 let result = registry.invoke("unknown_xyz", Path::new("test.src"), false);
617 assert!(result.is_err());
618 }
619
620 #[test]
621 fn builtin_specs_have_correct_binaries() {
622 let js = builtin_javascript_spec();
623 assert_eq!(js.binary_name, "node");
624 assert_eq!(js.compile_command, "node");
625
626 let ts = builtin_typescript_spec();
627 assert_eq!(ts.binary_name, "tsc");
628
629 let py = builtin_python_spec();
630 assert_eq!(py.binary_name, "python3");
631
632 let rs = builtin_rust_spec();
633 assert_eq!(rs.binary_name, "rustc");
634 assert!(rs.compile_args.contains(&"--edition".to_string()));
635 assert!(rs.compile_args.contains(&"2021".to_string()));
636
637 let go = builtin_go_spec();
638 assert_eq!(go.binary_name, "go");
639 assert!(go.compile_args.contains(&"vet".to_string()));
640 }
641
642 #[test]
643 fn detect_all_returns_report() {
644 let registry = ToolchainRegistry::with_builtins();
645 let report = registry.detect_all();
646 assert_eq!(report.found.len() + report.missing.len(), 5);
648 }
649
650 #[test]
651 fn toolchain_report_all_found() {
652 let registry = ToolchainRegistry::new();
654 let report = registry.detect_all();
655 assert!(report.all_found());
656 }
657
658 #[test]
659 fn detect_missing_binary_via_registry() {
660 let mut registry = ToolchainRegistry::new();
661 registry.register(ToolchainSpec {
662 target_id: "fake".to_string(),
663 display_name: "Fake".to_string(),
664 binary_name: "not_a_real_binary_abc_999".to_string(),
665 version_args: vec!["--version".to_string()],
666 compile_command: "not_a_real_binary_abc_999".to_string(),
667 compile_args: vec![],
668 install_hint: "Cannot install fake toolchain".to_string(),
669 });
670
671 let report = registry.detect_all();
672 assert!(!report.all_found());
673 assert_eq!(report.missing.len(), 1);
674 assert_eq!(report.missing[0].0, "fake");
675 }
676
677 #[test]
678 fn invoke_with_missing_toolchain_gives_clear_error() {
679 let mut registry = ToolchainRegistry::new();
680 registry.register(ToolchainSpec {
681 target_id: "fake".to_string(),
682 display_name: "Fake Lang".to_string(),
683 binary_name: "not_a_real_binary_zzz".to_string(),
684 version_args: vec!["--version".to_string()],
685 compile_command: "not_a_real_binary_zzz".to_string(),
686 compile_args: vec!["--check".to_string()],
687 install_hint: "Install from example.com".to_string(),
688 });
689
690 let result = registry.invoke("fake", Path::new("test.src"), false);
691 assert!(result.is_err());
692 let err = result.unwrap_err();
693 let msg = err.to_string();
694 assert!(msg.contains("not installed"));
695 assert!(msg.contains("Install from example.com"));
696 }
697}