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 unless
27    /// [`ToolchainSpec::validate_per_project`] is set).
28    pub compile_args: Vec<String>,
29    /// Whether the compile/validation command operates on the **whole emitted
30    /// project directory** rather than one source file at a time.
31    ///
32    /// `false` (the default for interpreted/single-file targets) means the
33    /// build driver runs `compile_command compile_args <file>` once per emitted
34    /// source file (e.g. `node --check main.js`). `true` (rust/go, whose
35    /// per-module output is a real Cargo crate / Go module — S3) means it runs
36    /// `compile_command compile_args` **once** with the output directory as the
37    /// working directory and **no** appended file path (e.g. `cargo check` /
38    /// `go build` in `build/<target>/`); a per-file `src/<module>.rs` references
39    /// `crate::…` paths and does not type-check in isolation.
40    pub validate_per_project: bool,
41    /// The plan for executing a built program and capturing its stdout.
42    ///
43    /// Distinct from [`ToolchainSpec::compile_command`]/[`ToolchainSpec::compile_args`],
44    /// which only *validate* generated source (e.g. `node --check`, `tsc --noEmit`).
45    /// Execution may require multiple ordered steps (e.g. Rust compiles then runs
46    /// the produced binary); see [`RunPlan`].
47    pub run_plan: RunPlan,
48    /// Human-readable install instructions shown when toolchain is missing.
49    pub install_hint: String,
50}
51
52/// How a [`RunStep`]'s `command` should be resolved when spawned.
53///
54/// This distinguishes "invoke a toolchain on PATH" from "execute a file the
55/// previous step produced in the working directory". The distinction is what
56/// makes execution cross-platform: a produced artifact is *not* on PATH and on
57/// Windows carries an `.exe` suffix, so it must be spawned by its workdir path
58/// (with [`std::env::consts::EXE_SUFFIX`] appended) rather than looked up like
59/// a toolchain binary.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum StepKind {
62    /// `command` is a toolchain binary resolved on PATH (e.g. `node`, `rustc`,
63    /// `python3`, `go`, `tsc`). Spawned by name; a spawn `NotFound` means the
64    /// toolchain is missing.
65    Toolchain,
66    /// `command` is the base name of an artifact a prior step produced in the
67    /// working directory (e.g. `main_bin`). It is spawned as
68    /// `<workdir>/<command><EXE_SUFFIX>` — never resolved on PATH — so it runs
69    /// portably on Windows (`main_bin.exe`) and Unix (`main_bin`) alike.
70    Artifact,
71}
72
73/// A single command in a target's execution plan.
74///
75/// Steps run in order from the program's working directory. The entrypoint
76/// file is always named `main.<ext>` (matching `bock build`'s emitted entry
77/// module), so steps reference it by literal name rather than an interpolated
78/// path. The *last* step's captured stdout is the program's output.
79#[derive(Debug, Clone)]
80pub struct RunStep {
81    /// The program to invoke. Resolution depends on [`RunStep::kind`]:
82    /// a [`StepKind::Toolchain`] command is looked up on PATH (e.g. `node`,
83    /// `python3`); a [`StepKind::Artifact`] command is the base name of a
84    /// produced binary spawned by its workdir path with the platform exe
85    /// suffix appended.
86    pub command: String,
87    /// Literal arguments passed to `command`, in order.
88    pub args: Vec<String>,
89    /// Whether `command` is a PATH-resolved toolchain or a produced artifact.
90    pub kind: StepKind,
91}
92
93impl RunStep {
94    /// Construct a PATH-resolved toolchain step from a command name and string
95    /// arguments (e.g. `node main.js`, `rustc … main.rs -o main_bin`).
96    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    /// Construct a step that executes a produced artifact in the working
105    /// directory. `name` is the artifact's base name (no exe suffix, no `./`):
106    /// the platform [`EXE_SUFFIX`](std::env::consts::EXE_SUFFIX) is appended and
107    /// the file is spawned by its workdir path, so it never goes through PATH /
108    /// toolchain detection. Used for compiled targets such as Rust whose
109    /// compile step writes `main_bin` (`main_bin.exe` on Windows).
110    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/// The ordered sequence of commands that compiles (if needed) and runs a
120/// generated program, capturing the final step's stdout.
121///
122/// Single-step targets (interpreted languages) have one entry; compiled targets
123/// such as Rust have a compile step followed by a run step. Every step must
124/// exit zero or [`ToolchainRegistry::run`] reports the failing step.
125#[derive(Debug, Clone)]
126pub struct RunPlan {
127    /// The steps to execute, in order. Must be non-empty.
128    pub steps: Vec<RunStep>,
129}
130
131/// Result of successfully detecting a toolchain.
132#[derive(Debug, Clone)]
133pub struct DetectedToolchain {
134    /// Target profile ID.
135    pub target_id: String,
136    /// Full path to the binary, or just the binary name if resolved via PATH.
137    pub binary_path: PathBuf,
138    /// Version string if detection succeeded.
139    pub version: Option<String>,
140}
141
142/// Result of invoking a target compilation.
143#[derive(Debug)]
144pub struct CompilationResult {
145    /// Target profile ID.
146    pub target_id: String,
147    /// The command that was executed.
148    pub command: String,
149    /// Standard output from the command.
150    pub stdout: String,
151    /// Standard error from the command.
152    pub stderr: String,
153    /// Whether the compilation succeeded.
154    pub success: bool,
155}
156
157/// Result of executing a generated program and capturing its output.
158///
159/// Returned by [`ToolchainRegistry::run`]. Unlike [`CompilationResult`], a
160/// non-zero `exit` is *not* an error at this layer — callers (e.g. the
161/// conformance execution harness) decide whether a given exit code or stdout
162/// constitutes a test failure.
163#[derive(Debug, Clone)]
164pub struct RunOutput {
165    /// Target profile ID the program was run under (e.g. "js", "rust").
166    pub target_id: String,
167    /// The full command pipeline that was executed, joined with `&&` for display.
168    pub command: String,
169    /// Captured standard output of the final step.
170    pub stdout: String,
171    /// Captured standard error of the final step.
172    pub stderr: String,
173    /// Process exit code of the final step, if the process exited normally.
174    pub exit: Option<i32>,
175}
176
177/// Errors that can occur during toolchain operations.
178#[derive(Debug)]
179pub enum ToolchainError {
180    /// The required toolchain binary was not found on PATH.
181    NotFound {
182        /// Target profile ID.
183        target_id: String,
184        /// Binary that was looked for.
185        binary_name: String,
186        /// Human-readable install instructions.
187        install_hint: String,
188    },
189    /// The toolchain was found but the compilation/validation command failed.
190    InvocationFailed {
191        /// Target profile ID.
192        target_id: String,
193        /// The full command that was run.
194        command: String,
195        /// Standard output (some compilers like tsc write errors here).
196        stdout: String,
197        /// Standard error output.
198        stderr: String,
199        /// Process exit code, if available.
200        exit_code: Option<i32>,
201    },
202    /// An I/O error occurred while invoking the toolchain.
203    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/// Registry of known toolchain specifications for all supported targets.
261#[derive(Debug)]
262pub struct ToolchainRegistry {
263    specs: HashMap<String, ToolchainSpec>,
264}
265
266impl ToolchainRegistry {
267    /// Creates a new empty registry.
268    #[must_use]
269    pub fn new() -> Self {
270        Self {
271            specs: HashMap::new(),
272        }
273    }
274
275    /// Creates a registry pre-populated with all built-in target toolchains.
276    #[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    /// Register a toolchain spec for a target.
288    pub fn register(&mut self, spec: ToolchainSpec) {
289        self.specs.insert(spec.target_id.clone(), spec);
290    }
291
292    /// Look up the toolchain spec for a target ID.
293    #[must_use]
294    pub fn get(&self, target_id: &str) -> Option<&ToolchainSpec> {
295        self.specs.get(target_id)
296    }
297
298    /// Returns all registered target IDs.
299    #[must_use]
300    pub fn target_ids(&self) -> Vec<&str> {
301        self.specs.keys().map(|s| s.as_str()).collect()
302    }
303
304    /// Detect whether a target's toolchain is installed.
305    ///
306    /// Checks for the binary on PATH and attempts to read its version.
307    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    /// Detect all registered toolchains, returning found and missing.
321    #[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    /// Invoke the compilation/validation command for a target.
337    ///
338    /// If `source_only` is true, skips compilation and returns immediately.
339    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        // First ensure the toolchain is installed
365        detect_toolchain(spec)?;
366
367        // Invoke the compile command
368        invoke_compile(spec, source_path)
369    }
370
371    /// Whether `target_id`'s validation runs once over the whole emitted project
372    /// directory (`cargo check` / `go build` for the per-module rust/go trees)
373    /// rather than once per source file. See
374    /// [`ToolchainSpec::validate_per_project`].
375    #[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    /// Validate a whole emitted project directory at once (for targets whose
383    /// per-module output is a real project — rust's Cargo crate, go's module).
384    ///
385    /// Runs `compile_command compile_args` with `project_dir` as the working
386    /// directory and **no** appended file path. Used in place of the per-file
387    /// [`ToolchainRegistry::invoke`] loop for [`ToolchainSpec::validate_per_project`]
388    /// targets, where a lone `src/<module>.rs` does not type-check in isolation.
389    ///
390    /// If `source_only` is true, skips validation and returns success.
391    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    /// Compile (if the target requires it) and execute a generated program,
419    /// capturing the final step's stdout/stderr/exit.
420    ///
421    /// `workdir` must contain the emitted entrypoint named `main.<ext>` (as
422    /// produced by `bock build` into `build/<target>/`). Each step in the
423    /// target's [`RunPlan`] runs with `workdir` as its current directory.
424    ///
425    /// # Errors
426    ///
427    /// * [`ToolchainError::NotFound`] if the toolchain binary is absent.
428    /// * [`ToolchainError::InvocationFailed`] if a non-final (compile) step
429    ///   exits non-zero — the generated program never produced output. A
430    ///   non-zero exit of the *final* step is **not** an error here; it is
431    ///   surfaced via [`RunOutput::exit`] for the caller to judge.
432    /// * [`ToolchainError::Io`] for other spawn failures.
433    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        // Ensure the primary toolchain is installed before attempting to run.
444        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/// Report of toolchain detection across all targets.
457#[derive(Debug)]
458pub struct ToolchainReport {
459    /// Successfully detected toolchains.
460    pub found: Vec<DetectedToolchain>,
461    /// Targets whose toolchain was not found, with the error.
462    pub missing: Vec<(String, ToolchainError)>,
463}
464
465impl ToolchainReport {
466    /// Returns true if all registered toolchains were found.
467    #[must_use]
468    pub fn all_found(&self) -> bool {
469        self.missing.is_empty()
470    }
471}
472
473// ---------------------------------------------------------------------------
474// Built-in toolchain specs
475// ---------------------------------------------------------------------------
476
477fn 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 the emitted ESM/CJS entry directly with Node.
487        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        // Validate the whole project via its `tsconfig.json` (`tsc --noEmit -p .`)
504        // rather than per file. The emitted tree imports sibling modules via `.ts`
505        // specifiers (so it runs under `node --experimental-strip-types`); a
506        // single-file `tsc --noEmit <file>` ignores the tsconfig, so it would
507        // reject the `.ts` specifier (TS5097). `validate_per_project` runs `tsc`
508        // once in `build/ts/`, where `rewriteRelativeImportExtensions` (from the
509        // scaffolded `tsconfig.json`) accepts the `.ts` extension.
510        compile_args: vec!["--noEmit".to_string(), "-p".to_string(), ".".to_string()],
511        validate_per_project: true,
512        // Compile the whole project via its `tsconfig.json` (`tsc -p .`), then
513        // run the emitted ESM entry with Node. Project mode (§20.6.2) always
514        // scaffolds a `tsconfig.json`; with it present in the run workdir, the
515        // old single-file `tsc main.ts` fails (TS5112: "tsconfig.json is present
516        // but will not be loaded if files are specified on commandline").
517        // Building by project resolves the config *and* compiles the whole
518        // per-module `.ts` tree (S6b). `rewriteRelativeImportExtensions` rewrites
519        // the `.ts` import specifiers to `.js` on emit so `node main.js` runs.
520        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 the emitted module directly with the Python 3 interpreter.
540        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        // Per the per-module native tree (S3, §20.6.1 / DQ19), the rust build
556        // output under `build/rust/` is a real Cargo crate, so validation is at
557        // the *crate* level (`cargo check` in the output dir) rather than
558        // per-file `rustc` — a `src/<module>.rs` file references `crate::…`
559        // paths and does not type-check in isolation. `validate_per_project`
560        // makes the build driver invoke this once in the output dir.
561        compile_command: "cargo".to_string(),
562        compile_args: vec!["check".to_string(), "--quiet".to_string()],
563        validate_per_project: true,
564        // `cargo run` from the crate root compiles the whole module tree and
565        // runs the `bock_app` binary. `--quiet` keeps cargo's build progress off
566        // stdout (the program's own stdout is what the harness captures); a debug
567        // build keeps it fast. This replaces the single-file `rustc main.rs`.
568        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        // Per the per-module native tree (S3), the go output under `build/go/`
582        // is a real Go module (`go.mod` + the per-module `.go` files in one
583        // `package main`), so it `go build`s as a *package* in the output dir
584        // rather than `go vet`-ing one file. `validate_per_project` makes the
585        // build driver invoke this once in the output dir.
586        compile_command: "go".to_string(),
587        compile_args: vec!["build".to_string(), "-o".to_string(), os_devnull()],
588        validate_per_project: true,
589        // `go run .` compiles and executes the whole package in the dir in one
590        // step — replacing the single-file `go run main.go`.
591        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
600/// The platform null device (`/dev/null` on Unix, `NUL` on Windows). Used as
601/// `go build -o` so crate-level validation discards the produced binary instead
602/// of leaving an artifact in the output dir.
603fn os_devnull() -> String {
604    if cfg!(windows) { "NUL" } else { "/dev/null" }.to_string()
605}
606
607// ---------------------------------------------------------------------------
608// Internal helpers
609// ---------------------------------------------------------------------------
610
611/// Check if a binary exists on PATH and get its version.
612fn detect_toolchain(spec: &ToolchainSpec) -> Result<DetectedToolchain, ToolchainError> {
613    // Try to find the binary using `which` equivalent — run the version command
614    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        // Both NotFound and PermissionDenied indicate the binary isn't usable
621        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
652/// Invoke the compile/validation command for a generated source file.
653fn 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
707/// Validate a whole emitted project directory at once: run
708/// `compile_command compile_args` with `project_dir` as the working directory
709/// and **no** appended file path (e.g. `cargo check` / `go build` in
710/// `build/<target>/`). Used for [`ToolchainSpec::validate_per_project`] targets
711/// (rust/go), whose per-module output is a real Cargo crate / Go module.
712fn 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
766/// Execute a target's [`RunPlan`] from `workdir`, returning the final step's output.
767///
768/// Compile/setup steps (every step except the last) must exit zero, or this
769/// returns [`ToolchainError::InvocationFailed`] for the failing step. The final
770/// step's output is captured and returned regardless of its exit code.
771fn 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        // Resolve the program to spawn. A toolchain step is looked up on PATH
792        // by name; a produced-artifact step is spawned by its workdir path with
793        // the platform exe suffix appended, so it never goes through PATH /
794        // toolchain detection (which is what broke Rust execution on Windows).
795        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            // A compile/setup step failed: the program never ran.
825            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        // Rust/Go emit a real project (Cargo crate / Go module — S3), so they
1041        // build and validate via the project toolchain (`cargo` / `go`) at the
1042        // crate/module level rather than per-file `rustc`/`go vet`.
1043        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        // Total should match number of builtins
1059        assert_eq!(report.found.len() + report.missing.len(), 5);
1060    }
1061
1062    #[test]
1063    fn toolchain_report_all_found() {
1064        // With an empty registry, all_found should be true (no missing)
1065        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        // Per the per-module native tree (S3), rust output is a Cargo crate run
1133        // via a single `cargo run` step from the build dir — replacing the old
1134        // two-step `rustc main.rs` + produced-artifact plan.
1135        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        // Per the per-module native tree (S3), go output is a Go module run via
1146        // `go run .` over the whole package dir — replacing `go run main.go`.
1147        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        // TS builds by project (`tsc -p .`) so the scaffolded `tsconfig.json`
1159        // (always present in project mode, §20.6.2 / S6b) is honored rather than
1160        // colliding with a single-file `tsc main.ts` (TS5112).
1161        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        // js/python run their entry directly; rust/go run their emitted project
1175        // via a single `cargo run` / `go run .` step (S3).
1176        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        // Use a portable program (`true`-like) to exercise the plumbing without
1228        // depending on a language toolchain. We register a custom spec whose
1229        // single step is a command guaranteed present on the test host.
1230        if Command::new("printf").arg("").output().is_err() {
1231            // `printf` not available on this host; skip rather than fail.
1232            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        // A two-step plan whose first (compile) step exits non-zero must surface
1258        // InvocationFailed, not reach the second step.
1259        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}