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>,
29 pub validate_per_project: bool,
41 pub run_plan: RunPlan,
48 pub install_hint: String,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum StepKind {
62 Toolchain,
66 Artifact,
71}
72
73#[derive(Debug, Clone)]
80pub struct RunStep {
81 pub command: String,
87 pub args: Vec<String>,
89 pub kind: StepKind,
91}
92
93impl RunStep {
94 pub fn new(command: impl Into<String>, args: &[&str]) -> Self {
97 Self {
98 command: command.into(),
99 args: args.iter().map(|a| (*a).to_string()).collect(),
100 kind: StepKind::Toolchain,
101 }
102 }
103
104 pub fn artifact(name: impl Into<String>) -> Self {
111 Self {
112 command: name.into(),
113 args: Vec::new(),
114 kind: StepKind::Artifact,
115 }
116 }
117}
118
119#[derive(Debug, Clone)]
126pub struct RunPlan {
127 pub steps: Vec<RunStep>,
129}
130
131#[derive(Debug, Clone)]
133pub struct DetectedToolchain {
134 pub target_id: String,
136 pub binary_path: PathBuf,
138 pub version: Option<String>,
140}
141
142#[derive(Debug)]
144pub struct CompilationResult {
145 pub target_id: String,
147 pub command: String,
149 pub stdout: String,
151 pub stderr: String,
153 pub success: bool,
155}
156
157#[derive(Debug, Clone)]
164pub struct RunOutput {
165 pub target_id: String,
167 pub command: String,
169 pub stdout: String,
171 pub stderr: String,
173 pub exit: Option<i32>,
175}
176
177#[derive(Debug)]
179pub enum ToolchainError {
180 NotFound {
182 target_id: String,
184 binary_name: String,
186 install_hint: String,
188 },
189 InvocationFailed {
191 target_id: String,
193 command: String,
195 stdout: String,
197 stderr: String,
199 exit_code: Option<i32>,
201 },
202 Io(std::io::Error),
204}
205
206impl fmt::Display for ToolchainError {
207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208 match self {
209 ToolchainError::NotFound {
210 target_id,
211 binary_name,
212 install_hint,
213 } => {
214 write!(
215 f,
216 "Toolchain not found for target '{target_id}': \
217 '{binary_name}' is not installed or not on PATH.\n\
218 To install: {install_hint}"
219 )
220 }
221 ToolchainError::InvocationFailed {
222 target_id,
223 command,
224 stdout,
225 stderr,
226 exit_code,
227 } => {
228 let diagnostic = if !stderr.is_empty() { stderr } else { stdout };
229 write!(
230 f,
231 "Compilation failed for target '{target_id}'.\n\
232 Command: {command}\n\
233 Exit code: {}\n\
234 output:\n{diagnostic}",
235 exit_code
236 .map(|c| c.to_string())
237 .unwrap_or_else(|| "signal".to_string())
238 )
239 }
240 ToolchainError::Io(err) => write!(f, "I/O error during toolchain invocation: {err}"),
241 }
242 }
243}
244
245impl std::error::Error for ToolchainError {
246 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
247 match self {
248 ToolchainError::Io(err) => Some(err),
249 _ => None,
250 }
251 }
252}
253
254impl From<std::io::Error> for ToolchainError {
255 fn from(err: std::io::Error) -> Self {
256 ToolchainError::Io(err)
257 }
258}
259
260#[derive(Debug)]
262pub struct ToolchainRegistry {
263 specs: HashMap<String, ToolchainSpec>,
264}
265
266impl ToolchainRegistry {
267 #[must_use]
269 pub fn new() -> Self {
270 Self {
271 specs: HashMap::new(),
272 }
273 }
274
275 #[must_use]
277 pub fn with_builtins() -> Self {
278 let mut registry = Self::new();
279 registry.register(builtin_javascript_spec());
280 registry.register(builtin_typescript_spec());
281 registry.register(builtin_python_spec());
282 registry.register(builtin_rust_spec());
283 registry.register(builtin_go_spec());
284 registry
285 }
286
287 pub fn register(&mut self, spec: ToolchainSpec) {
289 self.specs.insert(spec.target_id.clone(), spec);
290 }
291
292 #[must_use]
294 pub fn get(&self, target_id: &str) -> Option<&ToolchainSpec> {
295 self.specs.get(target_id)
296 }
297
298 #[must_use]
300 pub fn target_ids(&self) -> Vec<&str> {
301 self.specs.keys().map(|s| s.as_str()).collect()
302 }
303
304 pub fn detect(&self, target_id: &str) -> Result<DetectedToolchain, ToolchainError> {
308 let spec = self
309 .specs
310 .get(target_id)
311 .ok_or_else(|| ToolchainError::NotFound {
312 target_id: target_id.to_string(),
313 binary_name: target_id.to_string(),
314 install_hint: format!("No toolchain registered for target '{target_id}'"),
315 })?;
316
317 detect_toolchain(spec)
318 }
319
320 #[must_use]
322 pub fn detect_all(&self) -> ToolchainReport {
323 let mut found = Vec::new();
324 let mut missing = Vec::new();
325
326 for (target_id, spec) in &self.specs {
327 match detect_toolchain(spec) {
328 Ok(detected) => found.push(detected),
329 Err(err) => missing.push((target_id.clone(), err)),
330 }
331 }
332
333 ToolchainReport { found, missing }
334 }
335
336 pub fn invoke(
340 &self,
341 target_id: &str,
342 source_path: &Path,
343 source_only: bool,
344 ) -> Result<CompilationResult, ToolchainError> {
345 if source_only {
346 return Ok(CompilationResult {
347 target_id: target_id.to_string(),
348 command: "(source-only, compilation skipped)".to_string(),
349 stdout: String::new(),
350 stderr: String::new(),
351 success: true,
352 });
353 }
354
355 let spec = self
356 .specs
357 .get(target_id)
358 .ok_or_else(|| ToolchainError::NotFound {
359 target_id: target_id.to_string(),
360 binary_name: target_id.to_string(),
361 install_hint: format!("No toolchain registered for target '{target_id}'"),
362 })?;
363
364 detect_toolchain(spec)?;
366
367 invoke_compile(spec, source_path)
369 }
370
371 #[must_use]
376 pub fn validates_per_project(&self, target_id: &str) -> bool {
377 self.specs
378 .get(target_id)
379 .is_some_and(|s| s.validate_per_project)
380 }
381
382 pub fn invoke_project(
392 &self,
393 target_id: &str,
394 project_dir: &Path,
395 source_only: bool,
396 ) -> Result<CompilationResult, ToolchainError> {
397 if source_only {
398 return Ok(CompilationResult {
399 target_id: target_id.to_string(),
400 command: "(source-only, compilation skipped)".to_string(),
401 stdout: String::new(),
402 stderr: String::new(),
403 success: true,
404 });
405 }
406 let spec = self
407 .specs
408 .get(target_id)
409 .ok_or_else(|| ToolchainError::NotFound {
410 target_id: target_id.to_string(),
411 binary_name: target_id.to_string(),
412 install_hint: format!("No toolchain registered for target '{target_id}'"),
413 })?;
414 detect_toolchain(spec)?;
415 invoke_compile_in_dir(spec, project_dir)
416 }
417
418 pub fn run(&self, target_id: &str, workdir: &Path) -> Result<RunOutput, ToolchainError> {
434 let spec = self
435 .specs
436 .get(target_id)
437 .ok_or_else(|| ToolchainError::NotFound {
438 target_id: target_id.to_string(),
439 binary_name: target_id.to_string(),
440 install_hint: format!("No toolchain registered for target '{target_id}'"),
441 })?;
442
443 detect_toolchain(spec)?;
445
446 run_program(spec, workdir)
447 }
448}
449
450impl Default for ToolchainRegistry {
451 fn default() -> Self {
452 Self::with_builtins()
453 }
454}
455
456#[derive(Debug)]
458pub struct ToolchainReport {
459 pub found: Vec<DetectedToolchain>,
461 pub missing: Vec<(String, ToolchainError)>,
463}
464
465impl ToolchainReport {
466 #[must_use]
468 pub fn all_found(&self) -> bool {
469 self.missing.is_empty()
470 }
471}
472
473fn builtin_javascript_spec() -> ToolchainSpec {
478 ToolchainSpec {
479 target_id: "js".to_string(),
480 display_name: "Node.js".to_string(),
481 binary_name: "node".to_string(),
482 version_args: vec!["--version".to_string()],
483 compile_command: "node".to_string(),
484 compile_args: vec!["--check".to_string()],
485 validate_per_project: false,
486 run_plan: RunPlan {
488 steps: vec![RunStep::new("node", &["main.js"])],
489 },
490 install_hint: "Install Node.js from https://nodejs.org/ or via your package manager \
491 (e.g., `brew install node`, `apt install nodejs`)"
492 .to_string(),
493 }
494}
495
496fn builtin_typescript_spec() -> ToolchainSpec {
497 ToolchainSpec {
498 target_id: "ts".to_string(),
499 display_name: "TypeScript compiler".to_string(),
500 binary_name: "tsc".to_string(),
501 version_args: vec!["--version".to_string()],
502 compile_command: "tsc".to_string(),
503 compile_args: vec!["--noEmit".to_string(), "-p".to_string(), ".".to_string()],
511 validate_per_project: true,
512 run_plan: RunPlan {
521 steps: vec![
522 RunStep::new("tsc", &["-p", "."]),
523 RunStep::new("node", &["main.js"]),
524 ],
525 },
526 install_hint: "Install TypeScript via npm: `npm install -g typescript`".to_string(),
527 }
528}
529
530fn builtin_python_spec() -> ToolchainSpec {
531 ToolchainSpec {
532 target_id: "python".to_string(),
533 display_name: "Python 3".to_string(),
534 binary_name: "python3".to_string(),
535 version_args: vec!["--version".to_string()],
536 compile_command: "python3".to_string(),
537 compile_args: vec!["-m".to_string(), "py_compile".to_string()],
538 validate_per_project: false,
539 run_plan: RunPlan {
541 steps: vec![RunStep::new("python3", &["main.py"])],
542 },
543 install_hint: "Install Python 3 from https://python.org/ or via your package manager \
544 (e.g., `brew install python3`, `apt install python3`)"
545 .to_string(),
546 }
547}
548
549fn builtin_rust_spec() -> ToolchainSpec {
550 ToolchainSpec {
551 target_id: "rust".to_string(),
552 display_name: "Rust toolchain (cargo)".to_string(),
553 binary_name: "cargo".to_string(),
554 version_args: vec!["--version".to_string()],
555 compile_command: "cargo".to_string(),
562 compile_args: vec!["check".to_string(), "--quiet".to_string()],
563 validate_per_project: true,
564 run_plan: RunPlan {
569 steps: vec![RunStep::new("cargo", &["run", "--quiet"])],
570 },
571 install_hint: "Install Rust via rustup: https://rustup.rs/".to_string(),
572 }
573}
574
575fn builtin_go_spec() -> ToolchainSpec {
576 ToolchainSpec {
577 target_id: "go".to_string(),
578 display_name: "Go compiler".to_string(),
579 binary_name: "go".to_string(),
580 version_args: vec!["version".to_string()],
581 compile_command: "go".to_string(),
587 compile_args: vec!["build".to_string(), "-o".to_string(), os_devnull()],
588 validate_per_project: true,
589 run_plan: RunPlan {
592 steps: vec![RunStep::new("go", &["run", "."])],
593 },
594 install_hint: "Install Go from https://go.dev/dl/ or via your package manager \
595 (e.g., `brew install go`, `apt install golang`)"
596 .to_string(),
597 }
598}
599
600fn os_devnull() -> String {
604 if cfg!(windows) { "NUL" } else { "/dev/null" }.to_string()
605}
606
607fn detect_toolchain(spec: &ToolchainSpec) -> Result<DetectedToolchain, ToolchainError> {
613 let mut cmd = Command::new(&spec.binary_name);
615 for arg in &spec.version_args {
616 cmd.arg(arg);
617 }
618
619 let output = cmd.output().map_err(|e| {
620 if e.kind() == std::io::ErrorKind::NotFound
622 || e.kind() == std::io::ErrorKind::PermissionDenied
623 {
624 ToolchainError::NotFound {
625 target_id: spec.target_id.clone(),
626 binary_name: spec.binary_name.clone(),
627 install_hint: spec.install_hint.clone(),
628 }
629 } else {
630 ToolchainError::Io(e)
631 }
632 })?;
633
634 let version = if output.status.success() {
635 let v = String::from_utf8_lossy(&output.stdout).trim().to_string();
636 if v.is_empty() {
637 None
638 } else {
639 Some(v)
640 }
641 } else {
642 None
643 };
644
645 Ok(DetectedToolchain {
646 target_id: spec.target_id.clone(),
647 binary_path: PathBuf::from(&spec.binary_name),
648 version,
649 })
650}
651
652fn invoke_compile(
654 spec: &ToolchainSpec,
655 source_path: &Path,
656) -> Result<CompilationResult, ToolchainError> {
657 let mut cmd = Command::new(&spec.compile_command);
658 for arg in &spec.compile_args {
659 cmd.arg(arg);
660 }
661 cmd.arg(source_path);
662
663 let full_command = format!(
664 "{} {} {}",
665 spec.compile_command,
666 spec.compile_args.join(" "),
667 source_path.display()
668 );
669
670 let output = cmd.output().map_err(|e| {
671 if e.kind() == std::io::ErrorKind::NotFound
672 || e.kind() == std::io::ErrorKind::PermissionDenied
673 {
674 ToolchainError::NotFound {
675 target_id: spec.target_id.clone(),
676 binary_name: spec.compile_command.clone(),
677 install_hint: spec.install_hint.clone(),
678 }
679 } else {
680 ToolchainError::Io(e)
681 }
682 })?;
683
684 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
685 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
686 let success = output.status.success();
687
688 if !success {
689 return Err(ToolchainError::InvocationFailed {
690 target_id: spec.target_id.clone(),
691 command: full_command,
692 stdout: stdout.clone(),
693 stderr: stderr.clone(),
694 exit_code: output.status.code(),
695 });
696 }
697
698 Ok(CompilationResult {
699 target_id: spec.target_id.clone(),
700 command: full_command,
701 stdout,
702 stderr,
703 success,
704 })
705}
706
707fn invoke_compile_in_dir(
713 spec: &ToolchainSpec,
714 project_dir: &Path,
715) -> Result<CompilationResult, ToolchainError> {
716 let mut cmd = Command::new(&spec.compile_command);
717 for arg in &spec.compile_args {
718 cmd.arg(arg);
719 }
720 cmd.current_dir(project_dir);
721
722 let full_command = format!(
723 "{} {} (in {})",
724 spec.compile_command,
725 spec.compile_args.join(" "),
726 project_dir.display()
727 );
728
729 let output = cmd.output().map_err(|e| {
730 if e.kind() == std::io::ErrorKind::NotFound
731 || e.kind() == std::io::ErrorKind::PermissionDenied
732 {
733 ToolchainError::NotFound {
734 target_id: spec.target_id.clone(),
735 binary_name: spec.compile_command.clone(),
736 install_hint: spec.install_hint.clone(),
737 }
738 } else {
739 ToolchainError::Io(e)
740 }
741 })?;
742
743 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
744 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
745 let success = output.status.success();
746
747 if !success {
748 return Err(ToolchainError::InvocationFailed {
749 target_id: spec.target_id.clone(),
750 command: full_command,
751 stdout: stdout.clone(),
752 stderr: stderr.clone(),
753 exit_code: output.status.code(),
754 });
755 }
756
757 Ok(CompilationResult {
758 target_id: spec.target_id.clone(),
759 command: full_command,
760 stdout,
761 stderr,
762 success,
763 })
764}
765
766fn run_program(spec: &ToolchainSpec, workdir: &Path) -> Result<RunOutput, ToolchainError> {
772 let steps = &spec.run_plan.steps;
773 debug_assert!(!steps.is_empty(), "run plan must have at least one step");
774
775 let display = steps
776 .iter()
777 .map(|step| {
778 format!("{} {}", step.command, step.args.join(" "))
779 .trim()
780 .to_string()
781 })
782 .collect::<Vec<_>>()
783 .join(" && ");
784
785 let last_idx = steps.len().saturating_sub(1);
786 let mut final_stdout = String::new();
787 let mut final_stderr = String::new();
788 let mut final_exit: Option<i32> = None;
789
790 for (idx, step) in steps.iter().enumerate() {
791 let program = match step.kind {
796 StepKind::Toolchain => PathBuf::from(&step.command),
797 StepKind::Artifact => {
798 workdir.join(format!("{}{}", step.command, std::env::consts::EXE_SUFFIX))
799 }
800 };
801
802 let mut cmd = Command::new(&program);
803 cmd.args(&step.args);
804 cmd.current_dir(workdir);
805
806 let output = cmd.output().map_err(|e| {
807 if e.kind() == std::io::ErrorKind::NotFound
808 || e.kind() == std::io::ErrorKind::PermissionDenied
809 {
810 ToolchainError::NotFound {
811 target_id: spec.target_id.clone(),
812 binary_name: step.command.clone(),
813 install_hint: spec.install_hint.clone(),
814 }
815 } else {
816 ToolchainError::Io(e)
817 }
818 })?;
819
820 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
821 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
822
823 if idx != last_idx && !output.status.success() {
824 return Err(ToolchainError::InvocationFailed {
826 target_id: spec.target_id.clone(),
827 command: format!("{} {}", step.command, step.args.join(" "))
828 .trim()
829 .to_string(),
830 stdout,
831 stderr,
832 exit_code: output.status.code(),
833 });
834 }
835
836 final_stdout = stdout;
837 final_stderr = stderr;
838 final_exit = output.status.code();
839 }
840
841 Ok(RunOutput {
842 target_id: spec.target_id.clone(),
843 command: display,
844 stdout: final_stdout,
845 stderr: final_stderr,
846 exit: final_exit,
847 })
848}
849
850#[cfg(test)]
851mod tests {
852 use super::*;
853
854 #[test]
855 fn registry_with_builtins_has_all_targets() {
856 let registry = ToolchainRegistry::with_builtins();
857 assert!(registry.get("js").is_some());
858 assert!(registry.get("ts").is_some());
859 assert!(registry.get("python").is_some());
860 assert!(registry.get("rust").is_some());
861 assert!(registry.get("go").is_some());
862 assert_eq!(registry.target_ids().len(), 5);
863 }
864
865 #[test]
866 fn registry_default_equals_builtins() {
867 let registry = ToolchainRegistry::default();
868 assert_eq!(registry.target_ids().len(), 5);
869 }
870
871 #[test]
872 fn registry_custom_spec() {
873 let mut registry = ToolchainRegistry::new();
874 assert!(registry.get("custom").is_none());
875
876 registry.register(ToolchainSpec {
877 target_id: "custom".to_string(),
878 display_name: "Custom Lang".to_string(),
879 binary_name: "customc".to_string(),
880 version_args: vec!["--version".to_string()],
881 compile_command: "customc".to_string(),
882 compile_args: vec!["--check".to_string()],
883 validate_per_project: false,
884 run_plan: RunPlan {
885 steps: vec![RunStep::new("customc", &["main.custom"])],
886 },
887 install_hint: "Install custom-lang from example.com".to_string(),
888 });
889
890 assert!(registry.get("custom").is_some());
891 assert_eq!(registry.get("custom").unwrap().display_name, "Custom Lang");
892 }
893
894 #[test]
895 fn unknown_target_returns_not_found() {
896 let registry = ToolchainRegistry::with_builtins();
897 let result = registry.detect("unknown_target_xyz");
898 assert!(result.is_err());
899 match result.unwrap_err() {
900 ToolchainError::NotFound { target_id, .. } => {
901 assert_eq!(target_id, "unknown_target_xyz");
902 }
903 other => panic!("Expected NotFound, got: {other}"),
904 }
905 }
906
907 #[test]
908 fn missing_binary_returns_not_found_error() {
909 let spec = ToolchainSpec {
910 target_id: "fake".to_string(),
911 display_name: "Fake".to_string(),
912 binary_name: "definitely_not_a_real_binary_xyz_123".to_string(),
913 version_args: vec!["--version".to_string()],
914 compile_command: "definitely_not_a_real_binary_xyz_123".to_string(),
915 compile_args: vec![],
916 validate_per_project: false,
917 run_plan: RunPlan {
918 steps: vec![RunStep::new("definitely_not_a_real_binary_xyz_123", &[])],
919 },
920 install_hint: "This is a test".to_string(),
921 };
922
923 let result = detect_toolchain(&spec);
924 assert!(result.is_err());
925 match result.unwrap_err() {
926 ToolchainError::NotFound {
927 target_id,
928 binary_name,
929 install_hint,
930 } => {
931 assert_eq!(target_id, "fake");
932 assert_eq!(binary_name, "definitely_not_a_real_binary_xyz_123");
933 assert_eq!(install_hint, "This is a test");
934 }
935 other => panic!("Expected NotFound, got: {other}"),
936 }
937 }
938
939 #[test]
940 fn not_found_error_display_includes_install_hint() {
941 let err = ToolchainError::NotFound {
942 target_id: "rust".to_string(),
943 binary_name: "rustc".to_string(),
944 install_hint: "Install via rustup".to_string(),
945 };
946 let msg = err.to_string();
947 assert!(msg.contains("rust"));
948 assert!(msg.contains("rustc"));
949 assert!(msg.contains("Install via rustup"));
950 }
951
952 #[test]
953 fn invocation_failed_error_display() {
954 let err = ToolchainError::InvocationFailed {
955 target_id: "js".to_string(),
956 command: "node --check test.js".to_string(),
957 stdout: String::new(),
958 stderr: "SyntaxError: unexpected token".to_string(),
959 exit_code: Some(1),
960 };
961 let msg = err.to_string();
962 assert!(msg.contains("js"));
963 assert!(msg.contains("node --check test.js"));
964 assert!(msg.contains("SyntaxError"));
965 assert!(msg.contains("1"));
966 }
967
968 #[test]
969 fn invocation_failed_prefers_stderr_over_stdout() {
970 let err = ToolchainError::InvocationFailed {
971 target_id: "rust".to_string(),
972 command: "rustc test.rs".to_string(),
973 stdout: "ignored stdout".to_string(),
974 stderr: "real error on stderr".to_string(),
975 exit_code: Some(1),
976 };
977 let msg = err.to_string();
978 assert!(msg.contains("real error on stderr"));
979 assert!(!msg.contains("ignored stdout"));
980 }
981
982 #[test]
983 fn invocation_failed_falls_back_to_stdout() {
984 let err = ToolchainError::InvocationFailed {
985 target_id: "ts".to_string(),
986 command: "tsc --noEmit test.ts".to_string(),
987 stdout: "test.ts(1,1): error TS2304: Cannot find name 'x'.".to_string(),
988 stderr: String::new(),
989 exit_code: Some(2),
990 };
991 let msg = err.to_string();
992 assert!(msg.contains("error TS2304"));
993 assert!(msg.contains("Cannot find name"));
994 }
995
996 #[test]
997 fn source_only_skips_compilation() {
998 let registry = ToolchainRegistry::with_builtins();
999 let result = registry
1000 .invoke("js", Path::new("test.js"), true)
1001 .expect("source_only should always succeed");
1002
1003 assert!(result.success);
1004 assert!(result.command.contains("source-only"));
1005 assert_eq!(result.target_id, "js");
1006 }
1007
1008 #[test]
1009 fn source_only_works_for_any_target() {
1010 let registry = ToolchainRegistry::with_builtins();
1011
1012 for target in &["js", "ts", "python", "rust", "go"] {
1013 let result = registry
1014 .invoke(target, Path::new("test.src"), true)
1015 .expect("source_only should succeed for all targets");
1016 assert!(result.success);
1017 assert_eq!(result.target_id, *target);
1018 }
1019 }
1020
1021 #[test]
1022 fn invoke_unknown_target_returns_error() {
1023 let registry = ToolchainRegistry::with_builtins();
1024 let result = registry.invoke("unknown_xyz", Path::new("test.src"), false);
1025 assert!(result.is_err());
1026 }
1027
1028 #[test]
1029 fn builtin_specs_have_correct_binaries() {
1030 let js = builtin_javascript_spec();
1031 assert_eq!(js.binary_name, "node");
1032 assert_eq!(js.compile_command, "node");
1033
1034 let ts = builtin_typescript_spec();
1035 assert_eq!(ts.binary_name, "tsc");
1036
1037 let py = builtin_python_spec();
1038 assert_eq!(py.binary_name, "python3");
1039
1040 let rs = builtin_rust_spec();
1044 assert_eq!(rs.binary_name, "cargo");
1045 assert!(rs.compile_args.contains(&"check".to_string()));
1046 assert!(rs.validate_per_project);
1047
1048 let go = builtin_go_spec();
1049 assert_eq!(go.binary_name, "go");
1050 assert!(go.compile_args.contains(&"build".to_string()));
1051 assert!(go.validate_per_project);
1052 }
1053
1054 #[test]
1055 fn detect_all_returns_report() {
1056 let registry = ToolchainRegistry::with_builtins();
1057 let report = registry.detect_all();
1058 assert_eq!(report.found.len() + report.missing.len(), 5);
1060 }
1061
1062 #[test]
1063 fn toolchain_report_all_found() {
1064 let registry = ToolchainRegistry::new();
1066 let report = registry.detect_all();
1067 assert!(report.all_found());
1068 }
1069
1070 #[test]
1071 fn detect_missing_binary_via_registry() {
1072 let mut registry = ToolchainRegistry::new();
1073 registry.register(ToolchainSpec {
1074 target_id: "fake".to_string(),
1075 display_name: "Fake".to_string(),
1076 binary_name: "not_a_real_binary_abc_999".to_string(),
1077 version_args: vec!["--version".to_string()],
1078 compile_command: "not_a_real_binary_abc_999".to_string(),
1079 compile_args: vec![],
1080 validate_per_project: false,
1081 run_plan: RunPlan {
1082 steps: vec![RunStep::new("not_a_real_binary_abc_999", &[])],
1083 },
1084 install_hint: "Cannot install fake toolchain".to_string(),
1085 });
1086
1087 let report = registry.detect_all();
1088 assert!(!report.all_found());
1089 assert_eq!(report.missing.len(), 1);
1090 assert_eq!(report.missing[0].0, "fake");
1091 }
1092
1093 #[test]
1094 fn invoke_with_missing_toolchain_gives_clear_error() {
1095 let mut registry = ToolchainRegistry::new();
1096 registry.register(ToolchainSpec {
1097 target_id: "fake".to_string(),
1098 display_name: "Fake Lang".to_string(),
1099 binary_name: "not_a_real_binary_zzz".to_string(),
1100 version_args: vec!["--version".to_string()],
1101 compile_command: "not_a_real_binary_zzz".to_string(),
1102 compile_args: vec!["--check".to_string()],
1103 validate_per_project: false,
1104 run_plan: RunPlan {
1105 steps: vec![RunStep::new("not_a_real_binary_zzz", &[])],
1106 },
1107 install_hint: "Install from example.com".to_string(),
1108 });
1109
1110 let result = registry.invoke("fake", Path::new("test.src"), false);
1111 assert!(result.is_err());
1112 let err = result.unwrap_err();
1113 let msg = err.to_string();
1114 assert!(msg.contains("not installed"));
1115 assert!(msg.contains("Install from example.com"));
1116 }
1117
1118 #[test]
1119 fn builtin_specs_have_run_plans() {
1120 let registry = ToolchainRegistry::with_builtins();
1121 for target in &["js", "ts", "python", "rust", "go"] {
1122 let spec = registry.get(target).expect("builtin spec present");
1123 assert!(
1124 !spec.run_plan.steps.is_empty(),
1125 "{target} run plan must be non-empty"
1126 );
1127 }
1128 }
1129
1130 #[test]
1131 fn rust_run_plan_is_cargo_run() {
1132 let spec = builtin_rust_spec();
1136 assert_eq!(spec.binary_name, "cargo");
1137 assert_eq!(spec.run_plan.steps.len(), 1, "rust runs via `cargo run`");
1138 assert_eq!(spec.run_plan.steps[0].command, "cargo");
1139 assert_eq!(spec.run_plan.steps[0].kind, StepKind::Toolchain);
1140 assert!(spec.run_plan.steps[0].args.contains(&"run".to_string()));
1141 }
1142
1143 #[test]
1144 fn go_run_plan_is_go_run_dir() {
1145 let spec = builtin_go_spec();
1148 assert_eq!(spec.run_plan.steps.len(), 1, "go runs via `go run .`");
1149 assert_eq!(spec.run_plan.steps[0].command, "go");
1150 assert_eq!(
1151 spec.run_plan.steps[0].args,
1152 vec!["run".to_string(), ".".to_string()]
1153 );
1154 }
1155
1156 #[test]
1157 fn ts_run_plan_is_tsc_project_then_node() {
1158 let spec = builtin_typescript_spec();
1162 assert_eq!(spec.run_plan.steps.len(), 2, "ts emits then runs");
1163 assert_eq!(spec.run_plan.steps[0].command, "tsc");
1164 assert_eq!(
1165 spec.run_plan.steps[0].args,
1166 vec!["-p".to_string(), ".".to_string()]
1167 );
1168 assert_eq!(spec.run_plan.steps[1].command, "node");
1169 assert_eq!(spec.run_plan.steps[1].args, vec!["main.js".to_string()]);
1170 }
1171
1172 #[test]
1173 fn single_step_targets_run_one_command() {
1174 for spec in [
1177 builtin_javascript_spec(),
1178 builtin_python_spec(),
1179 builtin_rust_spec(),
1180 builtin_go_spec(),
1181 ] {
1182 assert_eq!(
1183 spec.run_plan.steps.len(),
1184 1,
1185 "{} should be a single run step",
1186 spec.target_id
1187 );
1188 }
1189 }
1190
1191 #[test]
1192 fn run_unknown_target_returns_not_found() {
1193 let registry = ToolchainRegistry::with_builtins();
1194 let result = registry.run("unknown_xyz", Path::new("."));
1195 assert!(result.is_err());
1196 match result.unwrap_err() {
1197 ToolchainError::NotFound { target_id, .. } => {
1198 assert_eq!(target_id, "unknown_xyz");
1199 }
1200 other => panic!("Expected NotFound, got: {other}"),
1201 }
1202 }
1203
1204 #[test]
1205 fn run_with_missing_toolchain_gives_not_found() {
1206 let mut registry = ToolchainRegistry::new();
1207 registry.register(ToolchainSpec {
1208 target_id: "fake".to_string(),
1209 display_name: "Fake Lang".to_string(),
1210 binary_name: "not_a_real_binary_run_xyz".to_string(),
1211 version_args: vec!["--version".to_string()],
1212 compile_command: "not_a_real_binary_run_xyz".to_string(),
1213 compile_args: vec![],
1214 validate_per_project: false,
1215 run_plan: RunPlan {
1216 steps: vec![RunStep::new("not_a_real_binary_run_xyz", &[])],
1217 },
1218 install_hint: "Install from example.com".to_string(),
1219 });
1220
1221 let result = registry.run("fake", Path::new("."));
1222 assert!(matches!(result, Err(ToolchainError::NotFound { .. })));
1223 }
1224
1225 #[test]
1226 fn run_captures_stdout_of_final_step() {
1227 if Command::new("printf").arg("").output().is_err() {
1231 return;
1233 }
1234 let mut registry = ToolchainRegistry::new();
1235 registry.register(ToolchainSpec {
1236 target_id: "printer".to_string(),
1237 display_name: "Printer".to_string(),
1238 binary_name: "printf".to_string(),
1239 version_args: vec!["%s".to_string(), "probe".to_string()],
1240 compile_command: "printf".to_string(),
1241 compile_args: vec![],
1242 validate_per_project: false,
1243 run_plan: RunPlan {
1244 steps: vec![RunStep::new("printf", &["%s", "captured-output"])],
1245 },
1246 install_hint: "coreutils".to_string(),
1247 });
1248
1249 let out = registry.run("printer", Path::new(".")).expect("run ok");
1250 assert_eq!(out.target_id, "printer");
1251 assert_eq!(out.stdout.trim(), "captured-output");
1252 assert_eq!(out.exit, Some(0));
1253 }
1254
1255 #[test]
1256 fn run_reports_failing_compile_step() {
1257 if Command::new("false").output().is_err() {
1260 return;
1261 }
1262 let mut registry = ToolchainRegistry::new();
1263 registry.register(ToolchainSpec {
1264 target_id: "twostep".to_string(),
1265 display_name: "Two Step".to_string(),
1266 binary_name: "false".to_string(),
1267 version_args: vec![],
1268 compile_command: "false".to_string(),
1269 compile_args: vec![],
1270 validate_per_project: false,
1271 run_plan: RunPlan {
1272 steps: vec![
1273 RunStep::new("false", &[]),
1274 RunStep::new("echo", &["unreached"]),
1275 ],
1276 },
1277 install_hint: "coreutils".to_string(),
1278 });
1279
1280 let result = registry.run("twostep", Path::new("."));
1281 match result {
1282 Err(ToolchainError::InvocationFailed { target_id, .. }) => {
1283 assert_eq!(target_id, "twostep");
1284 }
1285 other => panic!("expected InvocationFailed, got {other:?}"),
1286 }
1287 }
1288}