Skip to main content

aion_toolchain/
compile.rs

1//! Core authoring compile loop: stage an isolated working copy of the project
2//! template, write the submitted source into it, run `gleam build` there, and
3//! package on success.
4//!
5//! This module is the clean generalization of the `gleam build` shell-out
6//! already used by the local `aion package` command: it spawns the external
7//! `gleam` binary (no embedded compiler), but captures the compiler's output
8//! instead of inheriting stdio so the diagnostics can travel back over the
9//! wire, and it accepts a configurable binary path so the server can be
10//! pointed at an operator-provided `gleam`.
11//!
12//! # Per-submission isolation
13//!
14//! The configured project root is treated as a **read-only template**. Each
15//! call stages its own throwaway working copy (a [`Workspace`]) and writes,
16//! builds, and packages entirely inside that copy, so concurrent submissions
17//! are fully isolated — no shared entry-file, no shared `build/` directory, no
18//! shared `.aion` output, no global lock, and no pool-size cap (ADR-001). The
19//! working copy is removed when the [`Workspace`] drops, on every path.
20//!
21//! The toolchain never rewrites the author's source: it writes the submitted
22//! bytes verbatim into the entry module's file and packages the build output.
23//! The determinism boundary (invariant 2) is the author's responsibility and
24//! stays untouched here.
25
26use std::path::{Path, PathBuf};
27use std::process::Command;
28
29use aion_package::{Package, PackageOptions, WorkflowVersion, package_project};
30
31use crate::error::ToolchainError;
32use crate::project;
33use crate::workspace::Workspace;
34
35/// A request to compile, type-check, and package submitted Gleam source.
36pub struct CompileRequest<'a> {
37    /// The built Gleam workflow project **template** the submission is built
38    /// against. It must contain `gleam.toml`, `workflow.toml`, the `aion_flow`
39    /// dependency, and `schemas/` — exactly as `aion new` produces one. It is
40    /// read-only at request time: the toolchain copies it into a fresh
41    /// per-submission workspace and never writes to or builds in it.
42    pub template_root: &'a Path,
43    /// Path to the external `gleam` binary the toolchain spawns. There is no
44    /// default: the caller supplies it (the server resolves it from the
45    /// operator-configured `[authoring].gleam_path`).
46    pub gleam_path: &'a Path,
47    /// The submitted Gleam source written verbatim to the workspace copy's
48    /// single entry-module source file before building.
49    pub source: &'a str,
50}
51
52/// A compiled, type-checked, and packaged workflow.
53#[derive(Clone, Debug)]
54pub struct CompiledWorkflow {
55    /// The verified `.aion` package, re-loaded from disk after writing.
56    pub package: Package,
57    /// The canonical version record of the verified package.
58    pub version: WorkflowVersion,
59    /// The workflow type (the manifest entry module).
60    pub workflow_type: String,
61    /// The absolute path of the written `.aion` archive.
62    pub output_path: PathBuf,
63}
64
65/// Compiles, type-checks, and packages submitted Gleam source against a
66/// read-only project template, in a fresh per-submission workspace.
67///
68/// Validates the template is a usable single-workflow Gleam project, stages an
69/// isolated working copy of it (a [`Workspace`] — a sibling temp dir that is
70/// removed on drop), writes `request.source` into the working copy's single
71/// entry-module source file, runs `gleam build` in the working copy (capturing
72/// its output), and — only on a zero exit — packages the working copy into a
73/// verified `.aion`. The template is never written to or built in, so
74/// concurrent submissions are fully isolated.
75///
76/// This is synchronous and blocks on `gleam build` and packaging, both of
77/// which can run for seconds. Async callers MUST wrap it in a blocking task
78/// (for example `tokio::task::spawn_blocking`).
79///
80/// # Errors
81///
82/// Returns [`ToolchainError::InvalidProject`] when the template is not a usable
83/// single-workflow Gleam project, the entry module name is unsafe, or the
84/// template has no parent directory to host the workspace,
85/// [`ToolchainError::Io`] when the working copy cannot be staged or the source
86/// cannot be written, [`ToolchainError::GleamSpawn`] when the `gleam` binary
87/// cannot be spawned, [`ToolchainError::TypeCheck`] (carrying the verbatim
88/// compiler diagnostics) when the build exits non-zero, and
89/// [`ToolchainError::Packaging`] when the built project cannot be assembled
90/// into a verified archive.
91pub fn compile_source(request: &CompileRequest<'_>) -> Result<CompiledWorkflow, ToolchainError> {
92    compile_source_with_entry(request, None)
93}
94
95/// Compiles and packages submitted source under an explicit logical entry
96/// module instead of the frozen module declared by the project template.
97///
98/// The override is applied only inside the per-submission workspace: the
99/// template remains read-only, its other manifest policy is preserved, and the
100/// replaced template entry source is excluded from the resulting package. A
101/// document-owned root package name prevents the frozen template name from
102/// entering compiled BEAM paths. A clean root-package build prevents copied
103/// output from leaking stale modules while retaining dependency output. The
104/// generated document-package `@@main` bootstrap is removed before packaging
105/// because it is application shell machinery, not workflow runtime code. The
106/// canonical document name therefore owns module identity and the routing key.
107///
108/// # Errors
109///
110/// Returns the same errors as [`compile_source`], and
111/// [`ToolchainError::InvalidProject`] when `entry_module` is not a supported
112/// Gleam logical module name.
113pub fn compile_source_for_entry(
114    request: &CompileRequest<'_>,
115    entry_module: &str,
116) -> Result<CompiledWorkflow, ToolchainError> {
117    compile_source_with_entry(request, Some(entry_module))
118}
119
120fn compile_source_with_entry(
121    request: &CompileRequest<'_>,
122    requested_entry: Option<&str>,
123) -> Result<CompiledWorkflow, ToolchainError> {
124    // Validate the template up front so a misconfigured project root fails
125    // before the cost of staging a working copy.
126    project::validate_project_root(request.template_root)?;
127    let template_entry = project::single_entry_module(request.template_root)?;
128
129    // Every submission gets its own isolated working copy; the template is
130    // never touched. The workspace (and the captured source within it) is
131    // removed when `workspace` drops at the end of this function — on the
132    // success path and on every `?` early return alike.
133    let workspace = Workspace::stage(request.template_root)?;
134    let workspace_root = workspace.root();
135    let canonical_requested = requested_entry
136        .map(project::canonical_entry_module)
137        .transpose()?;
138    let explicit_entry = canonical_requested.is_some();
139    let entry_module = match &canonical_requested {
140        Some(entry) => entry.as_str(),
141        None => template_entry.as_str(),
142    };
143    if explicit_entry {
144        project::retarget_single_entry_module(workspace_root, entry_module)?;
145        let template_source = project::entry_module_source_path(workspace_root, &template_entry)?;
146        project::remove_entry_source(&template_source)?;
147        // The staged template may be prebuilt. Gleam does not prune BEAMs for
148        // deleted sources, so root-package incremental reuse would retain the
149        // frozen entry. Remove the OLD root output before renaming the package;
150        // dependency output is safe to preserve.
151        project::remove_staged_root_build(workspace_root)?;
152        // Gleam embeds this package name in the entry BEAM's generated-source
153        // path, so it must be document-owned before the clean root build.
154        project::retarget_root_package(workspace_root, entry_module)?;
155    }
156    let source_path = project::entry_module_source_path(workspace_root, entry_module)?;
157    project::write_entry_source(&source_path, request.source)?;
158    if explicit_entry {
159        build_project(workspace_root, request.gleam_path)?;
160        // Even under the document-owned package name, the generated application
161        // bootstrap is neither the workflow entry nor a dependency.
162        project::remove_generated_root_main_beam(workspace_root)?;
163        package_built_project(workspace_root)
164    } else {
165        compile_built_project(workspace_root, request.gleam_path)
166    }
167}
168
169/// Runs `gleam build` against `project_root` then packages it.
170fn compile_built_project(
171    project_root: &Path,
172    gleam_path: &Path,
173) -> Result<CompiledWorkflow, ToolchainError> {
174    build_project(project_root, gleam_path)?;
175    package_built_project(project_root)
176}
177
178/// Compiles and type-checks an on-disk Gleam workflow project in place by
179/// spawning the external `gleam` binary against `project_root`, capturing its
180/// diagnostics instead of inheriting stdio.
181///
182/// This is the single shell-out the toolchain owns: [`compile_source`] calls
183/// it against a per-submission workspace copy, and the local `aion dev` watch
184/// loop calls it directly against the author's project on disk. Neither path
185/// reinvents the `gleam build` invocation or its diagnostic capture.
186///
187/// A non-zero exit is a [`ToolchainError::TypeCheck`] carrying the verbatim
188/// compiler output (stderr, with any stdout appended): Gleam writes errors to
189/// stderr, but context may split across both streams, so both are captured.
190///
191/// This is synchronous and blocks on `gleam build`, which can run for seconds;
192/// async callers MUST wrap it in a blocking task.
193///
194/// # Errors
195///
196/// Returns [`ToolchainError::GleamSpawn`] when the `gleam` binary at
197/// `gleam_path` cannot be spawned, and [`ToolchainError::TypeCheck`] (carrying
198/// the verbatim compiler diagnostics) when the build exits non-zero.
199pub fn build_project(project_root: &Path, gleam_path: &Path) -> Result<(), ToolchainError> {
200    let output = Command::new(gleam_path)
201        .arg("build")
202        .current_dir(project_root)
203        .output()
204        .map_err(|source| ToolchainError::GleamSpawn {
205            gleam_path: gleam_path.to_path_buf(),
206            source,
207        })?;
208    if output.status.success() {
209        return Ok(());
210    }
211    Err(ToolchainError::TypeCheck {
212        diagnostics: combine_diagnostics(&output.stderr, &output.stdout),
213    })
214}
215
216/// Joins captured stderr and stdout into the inline diagnostics string.
217///
218/// Stderr leads (Gleam's errors land there); stdout is appended only when it
219/// carries content, separated by a blank line.
220fn combine_diagnostics(stderr: &[u8], stdout: &[u8]) -> String {
221    let stderr = String::from_utf8_lossy(stderr);
222    let stdout = String::from_utf8_lossy(stdout);
223    let stderr_trimmed = stderr.trim_end();
224    let stdout_trimmed = stdout.trim_end();
225    match (stderr_trimmed.is_empty(), stdout_trimmed.is_empty()) {
226        (false, false) => format!("{stderr_trimmed}\n\n{stdout_trimmed}"),
227        (false, true) => stderr_trimmed.to_owned(),
228        (true, false) => stdout_trimmed.to_owned(),
229        (true, true) => {
230            "gleam build failed with no diagnostic output on stderr or stdout".to_owned()
231        }
232    }
233}
234
235/// Packages the built project into a verified single-workflow `.aion`.
236fn package_built_project(project_root: &Path) -> Result<CompiledWorkflow, ToolchainError> {
237    let report = package_project(project_root, &PackageOptions::default())?;
238    let mut built = report.packages;
239    let packaged = match built.len() {
240        1 => built.remove(0),
241        count => {
242            return Err(ToolchainError::InvalidProject {
243                message: format!(
244                    "authoring project packaged {count} workflows; source submission requires exactly one"
245                ),
246            });
247        }
248    };
249    Ok(CompiledWorkflow {
250        workflow_type: packaged.workflow_type,
251        output_path: packaged.output_path,
252        version: packaged.version,
253        package: packaged.package,
254    })
255}
256
257#[cfg(test)]
258mod tests {
259    use super::combine_diagnostics;
260
261    #[test]
262    fn diagnostics_prefer_stderr_and_append_stdout() {
263        assert_eq!(combine_diagnostics(b"type error\n", b""), "type error");
264        assert_eq!(combine_diagnostics(b"", b"compiling\n"), "compiling");
265        assert_eq!(
266            combine_diagnostics(b"type error\n", b"compiling demo\n"),
267            "type error\n\ncompiling demo"
268        );
269        assert_eq!(
270            combine_diagnostics(b"", b""),
271            "gleam build failed with no diagnostic output on stderr or stdout"
272        );
273    }
274}