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}