Skip to main content

bock_build/
toolchain.rs

1//! Toolchain detection and invocation for target compilation.
2//!
3//! After code generation produces target-language source files, this module
4//! detects installed toolchains (node, rustc, go, python3, tsc) and invokes
5//! the appropriate build/validation commands per target profile.
6
7use std::collections::HashMap;
8use std::fmt;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12/// Information about a target's toolchain requirements.
13#[derive(Debug, Clone)]
14pub struct ToolchainSpec {
15    /// Target profile ID (e.g., "js", "rust", "go").
16    pub target_id: String,
17    /// Display name for error messages (e.g., "Node.js", "Rust compiler").
18    pub display_name: String,
19    /// Primary binary name to locate on PATH (e.g., "node", "rustc").
20    pub binary_name: String,
21    /// Arguments to get the toolchain version (e.g., ["--version"]).
22    pub version_args: Vec<String>,
23    /// Command and arguments used to validate/compile generated source.
24    /// The source file path is appended as the last argument.
25    pub compile_command: String,
26    /// Arguments for the compile command (source path appended).
27    pub compile_args: Vec<String>,
28    /// Human-readable install instructions shown when toolchain is missing.
29    pub install_hint: String,
30}
31
32/// Result of successfully detecting a toolchain.
33#[derive(Debug, Clone)]
34pub struct DetectedToolchain {
35    /// Target profile ID.
36    pub target_id: String,
37    /// Full path to the binary, or just the binary name if resolved via PATH.
38    pub binary_path: PathBuf,
39    /// Version string if detection succeeded.
40    pub version: Option<String>,
41}
42
43/// Result of invoking a target compilation.
44#[derive(Debug)]
45pub struct CompilationResult {
46    /// Target profile ID.
47    pub target_id: String,
48    /// The command that was executed.
49    pub command: String,
50    /// Standard output from the command.
51    pub stdout: String,
52    /// Standard error from the command.
53    pub stderr: String,
54    /// Whether the compilation succeeded.
55    pub success: bool,
56}
57
58/// Errors that can occur during toolchain operations.
59#[derive(Debug)]
60pub enum ToolchainError {
61    /// The required toolchain binary was not found on PATH.
62    NotFound {
63        /// Target profile ID.
64        target_id: String,
65        /// Binary that was looked for.
66        binary_name: String,
67        /// Human-readable install instructions.
68        install_hint: String,
69    },
70    /// The toolchain was found but the compilation/validation command failed.
71    InvocationFailed {
72        /// Target profile ID.
73        target_id: String,
74        /// The full command that was run.
75        command: String,
76        /// Standard output (some compilers like tsc write errors here).
77        stdout: String,
78        /// Standard error output.
79        stderr: String,
80        /// Process exit code, if available.
81        exit_code: Option<i32>,
82    },
83    /// An I/O error occurred while invoking the toolchain.
84    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/// Registry of known toolchain specifications for all supported targets.
146#[derive(Debug)]
147pub struct ToolchainRegistry {
148    specs: HashMap<String, ToolchainSpec>,
149}
150
151impl ToolchainRegistry {
152    /// Creates a new empty registry.
153    #[must_use]
154    pub fn new() -> Self {
155        Self {
156            specs: HashMap::new(),
157        }
158    }
159
160    /// Creates a registry pre-populated with all built-in target toolchains.
161    #[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    /// Register a toolchain spec for a target.
173    pub fn register(&mut self, spec: ToolchainSpec) {
174        self.specs.insert(spec.target_id.clone(), spec);
175    }
176
177    /// Look up the toolchain spec for a target ID.
178    #[must_use]
179    pub fn get(&self, target_id: &str) -> Option<&ToolchainSpec> {
180        self.specs.get(target_id)
181    }
182
183    /// Returns all registered target IDs.
184    #[must_use]
185    pub fn target_ids(&self) -> Vec<&str> {
186        self.specs.keys().map(|s| s.as_str()).collect()
187    }
188
189    /// Detect whether a target's toolchain is installed.
190    ///
191    /// Checks for the binary on PATH and attempts to read its version.
192    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    /// Detect all registered toolchains, returning found and missing.
206    #[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    /// Invoke the compilation/validation command for a target.
222    ///
223    /// If `source_only` is true, skips compilation and returns immediately.
224    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        // First ensure the toolchain is installed
250        detect_toolchain(spec)?;
251
252        // Invoke the compile command
253        invoke_compile(spec, source_path)
254    }
255}
256
257impl Default for ToolchainRegistry {
258    fn default() -> Self {
259        Self::with_builtins()
260    }
261}
262
263/// Report of toolchain detection across all targets.
264#[derive(Debug)]
265pub struct ToolchainReport {
266    /// Successfully detected toolchains.
267    pub found: Vec<DetectedToolchain>,
268    /// Targets whose toolchain was not found, with the error.
269    pub missing: Vec<(String, ToolchainError)>,
270}
271
272impl ToolchainReport {
273    /// Returns true if all registered toolchains were found.
274    #[must_use]
275    pub fn all_found(&self) -> bool {
276        self.missing.is_empty()
277    }
278}
279
280// ---------------------------------------------------------------------------
281// Built-in toolchain specs
282// ---------------------------------------------------------------------------
283
284fn 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
350// ---------------------------------------------------------------------------
351// Internal helpers
352// ---------------------------------------------------------------------------
353
354/// Check if a binary exists on PATH and get its version.
355fn detect_toolchain(spec: &ToolchainSpec) -> Result<DetectedToolchain, ToolchainError> {
356    // Try to find the binary using `which` equivalent — run the version command
357    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        // Both NotFound and PermissionDenied indicate the binary isn't usable
364        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
395/// Invoke the compile/validation command for a generated source file.
396fn 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        // Total should match number of builtins
647        assert_eq!(report.found.len() + report.missing.len(), 5);
648    }
649
650    #[test]
651    fn toolchain_report_all_found() {
652        // With an empty registry, all_found should be true (no missing)
653        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}