Skip to main content

aion_toolchain/
project.rs

1//! Project-root helpers for the authoring toolchain.
2//!
3//! The toolchain operates on a project root laid out exactly as `aion new`
4//! and the examples produce one: a `gleam.toml`, a `workflow.toml`, a `src/`
5//! tree, and `schemas/`. These helpers validate that the root is a usable
6//! Gleam workflow project and resolve the on-disk path of the entry module's
7//! source file, confining every write inside the project's `src/` directory
8//! so a network-facing submission can never escape the root.
9
10use std::{
11    collections::BTreeMap,
12    path::{Component, Path, PathBuf},
13};
14
15use crate::error::ToolchainError;
16
17/// File name of the Gleam project manifest.
18const GLEAM_CONFIG_FILE: &str = "gleam.toml";
19
20/// File name of the workflow packaging descriptor.
21const WORKFLOW_CONFIG_FILE: &str = "workflow.toml";
22
23/// The minimal `workflow.toml` shape this crate reads to derive the entry
24/// module. Packaging proper re-parses the full descriptor through
25/// `aion-package`; here we only need the declared entry modules, so unknown
26/// keys are tolerated.
27#[derive(serde::Deserialize)]
28struct EntryConfig {
29    #[serde(default)]
30    workflow: Vec<EntryWorkflow>,
31}
32
33#[derive(serde::Deserialize)]
34struct EntryWorkflow {
35    entry_module: String,
36}
37
38#[derive(serde::Deserialize)]
39struct GleamProjectConfig {
40    name: String,
41    #[serde(default)]
42    dependencies: BTreeMap<String, toml::Value>,
43    #[serde(default, rename = "dev-dependencies")]
44    dev_dependencies: BTreeMap<String, toml::Value>,
45}
46
47/// Validates that `root` is a usable Gleam workflow project: it must contain
48/// both a `gleam.toml` and a `workflow.toml`.
49///
50/// # Errors
51///
52/// Returns [`ToolchainError::InvalidProject`] when either manifest is absent.
53pub fn validate_project_root(root: &Path) -> Result<(), ToolchainError> {
54    if !root.join(GLEAM_CONFIG_FILE).is_file() {
55        return Err(ToolchainError::InvalidProject {
56            message: format!(
57                "{} not found under the authoring project root `{}`; the root must be a built Gleam project",
58                GLEAM_CONFIG_FILE,
59                root.display()
60            ),
61        });
62    }
63    if !root.join(WORKFLOW_CONFIG_FILE).is_file() {
64        return Err(ToolchainError::InvalidProject {
65            message: format!(
66                "{} not found under the authoring project root `{}`; the root must declare its workflow packaging descriptor",
67                WORKFLOW_CONFIG_FILE,
68                root.display()
69            ),
70        });
71    }
72    Ok(())
73}
74
75/// Derives the single entry module declared by `<root>/workflow.toml`.
76///
77/// Submitting source is only meaningful for a single-workflow project: the
78/// submitted Gleam is written to that one entry module's source file. A
79/// project declaring zero or many workflows is rejected rather than guessed.
80///
81/// # Errors
82///
83/// Returns [`ToolchainError::Io`] when the descriptor cannot be read,
84/// [`ToolchainError::InvalidProject`] when it cannot be parsed or does not
85/// declare exactly one workflow.
86pub fn single_entry_module(root: &Path) -> Result<String, ToolchainError> {
87    let descriptor = root.join(WORKFLOW_CONFIG_FILE);
88    let text = std::fs::read_to_string(&descriptor).map_err(|source| ToolchainError::Io {
89        path: descriptor.clone(),
90        source,
91    })?;
92    let config: EntryConfig =
93        toml::from_str(&text).map_err(|source| ToolchainError::InvalidProject {
94            message: format!("failed to parse {}: {source}", descriptor.display()),
95        })?;
96    match config.workflow.as_slice() {
97        [single] => Ok(single.entry_module.clone()),
98        [] => Err(ToolchainError::InvalidProject {
99            message: format!(
100                "{} declares no [[workflow]] entry; source submission requires exactly one",
101                descriptor.display()
102            ),
103        }),
104        many => Err(ToolchainError::InvalidProject {
105            message: format!(
106                "{} declares {} [[workflow]] entries; source submission requires exactly one entry module to write the submitted source into",
107                descriptor.display(),
108                many.len()
109            ),
110        }),
111    }
112}
113
114/// Validates and canonicalizes a supported Gleam logical module name.
115///
116/// Source-style `/` nesting and BEAM-style `@` nesting are accepted at the API
117/// boundary. Canonical identity always uses `@`, matching Gleam's compiled BEAM
118/// name and the value `aion-package` discovers.
119///
120/// # Errors
121///
122/// Returns [`ToolchainError::InvalidProject`] when any component does not match
123/// `[a-z][a-z0-9_]*`.
124pub(crate) fn canonical_entry_module(entry_module: &str) -> Result<String, ToolchainError> {
125    if !is_supported_logical_module(entry_module) {
126        return Err(ToolchainError::InvalidProject {
127            message: format!(
128                "entry module `{entry_module}` is not a supported Gleam logical module name (each `/` or `@` separated component must match `[a-z][a-z0-9_]*`)"
129            ),
130        });
131    }
132    Ok(entry_module.replace('/', "@"))
133}
134
135/// Resolves the on-disk source path for `entry_module` under `<root>/src`,
136/// confining it to that directory.
137///
138/// Gleam's internal nested-module separator `@` maps to a path separator under
139/// `src/` (`demo@nested` -> `src/demo/nested.gleam`); source-style `/` separators
140/// are also accepted. Every component must match `[a-z][a-z0-9_]*`, the exact
141/// module grammar this toolchain supports, before any path is built.
142///
143/// # Errors
144///
145/// Returns [`ToolchainError::InvalidProject`] when the module name is not a
146/// supported Gleam logical name or the resolved path escapes `<root>/src`.
147pub fn entry_module_source_path(
148    root: &Path,
149    entry_module: &str,
150) -> Result<PathBuf, ToolchainError> {
151    let canonical = canonical_entry_module(entry_module)?;
152    let src_root = root.join("src");
153    let relative: PathBuf = canonical.split('@').collect::<PathBuf>();
154    let mut candidate = src_root.join(relative);
155    candidate.set_extension("gleam");
156
157    // Defence in depth: even though the module grammar rejects traversal,
158    // confirm lexically that the resolved path stays under
159    // `<root>/src` before it is ever handed to the filesystem.
160    if !is_confined(&src_root, &candidate) {
161        return Err(ToolchainError::InvalidProject {
162            message: format!(
163                "entry module `{entry_module}` resolves outside the project src directory `{}`",
164                src_root.display()
165            ),
166        });
167    }
168    Ok(candidate)
169}
170
171/// Writes `source` to the entry module's source file, creating any parent
172/// module directories first.
173///
174/// # Errors
175///
176/// Returns [`ToolchainError::Io`] when a parent directory cannot be created or
177/// the file cannot be written.
178pub fn write_entry_source(path: &Path, source: &str) -> Result<(), ToolchainError> {
179    if let Some(parent) = path.parent() {
180        std::fs::create_dir_all(parent).map_err(|io| ToolchainError::Io {
181            path: parent.to_path_buf(),
182            source: io,
183        })?;
184    }
185    std::fs::write(path, source.as_bytes()).map_err(|io| ToolchainError::Io {
186        path: path.to_path_buf(),
187        source: io,
188    })
189}
190
191/// Replaces the staged descriptor's sole entry module while preserving all
192/// other workflow packaging policy from the operator's template.
193///
194/// This is deliberately limited to a staged workspace: callers retain the
195/// configured template as read-only, while a document-aware submission can
196/// compile and package under its own logical module name.
197pub(crate) fn retarget_single_entry_module(
198    root: &Path,
199    entry_module: &str,
200) -> Result<(), ToolchainError> {
201    let entry_module = canonical_entry_module(entry_module)?;
202    drop(entry_module_source_path(root, &entry_module)?);
203    let descriptor = root.join(WORKFLOW_CONFIG_FILE);
204    let text = std::fs::read_to_string(&descriptor).map_err(|source| ToolchainError::Io {
205        path: descriptor.clone(),
206        source,
207    })?;
208    let mut config: toml::Value =
209        toml::from_str(&text).map_err(|source| ToolchainError::InvalidProject {
210            message: format!("failed to parse {}: {source}", descriptor.display()),
211        })?;
212    let workflows = config
213        .get_mut("workflow")
214        .and_then(toml::Value::as_array_mut)
215        .ok_or_else(|| ToolchainError::InvalidProject {
216            message: format!(
217                "{} declares no [[workflow]] entry; source submission requires exactly one",
218                descriptor.display()
219            ),
220        })?;
221    let workflow = match workflows.as_mut_slice() {
222        [single] => single,
223        many => {
224            return Err(ToolchainError::InvalidProject {
225                message: format!(
226                    "{} declares {} [[workflow]] entries; source submission requires exactly one entry module to write the submitted source into",
227                    descriptor.display(),
228                    many.len()
229                ),
230            });
231        }
232    };
233    let table = workflow
234        .as_table_mut()
235        .ok_or_else(|| ToolchainError::InvalidProject {
236            message: format!(
237                "{} contains a non-table [[workflow]] entry",
238                descriptor.display()
239            ),
240        })?;
241    table.insert("entry_module".to_owned(), toml::Value::String(entry_module));
242    let adjusted = toml::to_string(&config).map_err(|source| ToolchainError::InvalidProject {
243        message: format!("failed to serialize {}: {source}", descriptor.display()),
244    })?;
245    std::fs::write(&descriptor, adjusted).map_err(|source| ToolchainError::Io {
246        path: descriptor,
247        source,
248    })
249}
250
251/// Removes the frozen entry source from a staged workspace after its descriptor
252/// has been retargeted. A missing source is valid because template validation
253/// requires the manifest, not a pre-existing placeholder module.
254pub(crate) fn remove_entry_source(path: &Path) -> Result<(), ToolchainError> {
255    match std::fs::remove_file(path) {
256        Ok(()) => Ok(()),
257        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
258        Err(source) => Err(ToolchainError::Io {
259            path: path.to_path_buf(),
260            source,
261        }),
262    }
263}
264
265/// Removes the root package's compiler output copied from a prebuilt template.
266///
267/// Gleam's incremental build does not prune a `.beam` after its source is
268/// removed. Explicit-entry submissions replace the frozen entry source, so the
269/// staged root package must rebuild from empty output. Dependency outputs remain
270/// available: they cannot contain the replaced first-party module and retaining
271/// them avoids an unnecessary network resolution during an authoring deploy.
272pub(crate) fn remove_staged_root_build(root: &Path) -> Result<(), ToolchainError> {
273    let package = root_package_name(root)?;
274    let build = root.join("build").join("dev").join("erlang").join(package);
275    match std::fs::remove_dir_all(&build) {
276        Ok(()) => Ok(()),
277        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
278        Err(source) => Err(ToolchainError::Io {
279            path: build,
280            source,
281        }),
282    }
283}
284
285/// Retargets the staged Gleam root package to a document-owned name.
286///
287/// Flat entries use their canonical workflow name directly. Nested `@` module
288/// separators become `__`, producing a deterministic valid Gleam package name
289/// while the workflow manifest retains canonical `@` module identity. A name
290/// already claimed by a dependency is refused rather than shadowing that
291/// dependency in the build graph.
292pub(crate) fn retarget_root_package(root: &Path, entry_module: &str) -> Result<(), ToolchainError> {
293    let canonical = canonical_entry_module(entry_module)?;
294    let package = document_package_name(&canonical);
295    let descriptor = root.join(GLEAM_CONFIG_FILE);
296    let text = std::fs::read_to_string(&descriptor).map_err(|source| ToolchainError::Io {
297        path: descriptor.clone(),
298        source,
299    })?;
300    let project: GleamProjectConfig =
301        toml::from_str(&text).map_err(|source| ToolchainError::InvalidProject {
302            message: format!("failed to parse {}: {source}", descriptor.display()),
303        })?;
304    if project.dependencies.contains_key(&package)
305        || project.dev_dependencies.contains_key(&package)
306    {
307        return Err(ToolchainError::InvalidProject {
308            message: format!(
309                "document package name `{package}` derived from entry module `{canonical}` collides with a Gleam dependency"
310            ),
311        });
312    }
313
314    let mut config: toml::Value =
315        toml::from_str(&text).map_err(|source| ToolchainError::InvalidProject {
316            message: format!("failed to parse {}: {source}", descriptor.display()),
317        })?;
318    let table = config
319        .as_table_mut()
320        .ok_or_else(|| ToolchainError::InvalidProject {
321            message: format!("{} must contain a TOML table", descriptor.display()),
322        })?;
323    table.insert("name".to_owned(), toml::Value::String(package));
324    let adjusted = toml::to_string(&config).map_err(|source| ToolchainError::InvalidProject {
325        message: format!("failed to serialize {}: {source}", descriptor.display()),
326    })?;
327    std::fs::write(&descriptor, adjusted).map_err(|source| ToolchainError::Io {
328        path: descriptor,
329        source,
330    })
331}
332
333fn document_package_name(canonical_entry: &str) -> String {
334    canonical_entry.replace('@', "__")
335}
336
337/// Removes Gleam's generated root-package application bootstrap after a clean
338/// explicit-entry build.
339///
340/// The root package is now document-owned, but `<package>@@main.beam` remains a
341/// generated application bootstrap rather than emitted workflow code or a
342/// runtime dependency. Excluding it keeps compiler shell machinery out of the
343/// workflow version. Generic project compilation retains it; this ruling is
344/// specific to document packages assembled by `compile_source_for_entry`.
345pub(crate) fn remove_generated_root_main_beam(root: &Path) -> Result<(), ToolchainError> {
346    let package = root_package_name(root)?;
347    let artifact = root
348        .join("build")
349        .join("dev")
350        .join("erlang")
351        .join(&package)
352        .join("ebin")
353        .join(format!("{package}@@main.beam"));
354    match std::fs::remove_file(&artifact) {
355        Ok(()) => Ok(()),
356        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
357        Err(source) => Err(ToolchainError::Io {
358            path: artifact,
359            source,
360        }),
361    }
362}
363
364fn root_package_name(root: &Path) -> Result<String, ToolchainError> {
365    let descriptor = root.join(GLEAM_CONFIG_FILE);
366    let text = std::fs::read_to_string(&descriptor).map_err(|source| ToolchainError::Io {
367        path: descriptor.clone(),
368        source,
369    })?;
370    let config: GleamProjectConfig =
371        toml::from_str(&text).map_err(|source| ToolchainError::InvalidProject {
372            message: format!("failed to parse {}: {source}", descriptor.display()),
373        })?;
374    if !is_gleam_name_component(&config.name) {
375        return Err(ToolchainError::InvalidProject {
376            message: format!(
377                "Gleam package name `{}` must match `[a-z][a-z0-9_]*`",
378                config.name
379            ),
380        });
381    }
382    Ok(config.name)
383}
384
385/// Whether `candidate`, folded lexically, stays inside `base`.
386///
387/// Folds `.` and `..` components without touching the filesystem so the check
388/// holds even before the target file exists.
389fn is_confined(base: &Path, candidate: &Path) -> bool {
390    let mut depth: i64 = 0;
391    let Ok(relative) = candidate.strip_prefix(base) else {
392        return false;
393    };
394    for component in relative.components() {
395        match component {
396            Component::CurDir => {}
397            Component::Normal(_) => depth += 1,
398            Component::ParentDir => {
399                depth -= 1;
400                if depth < 0 {
401                    return false;
402                }
403            }
404            // An absolute or prefix component inside the relative remainder
405            // means the path was not actually under `base`.
406            Component::RootDir | Component::Prefix(_) => return false,
407        }
408    }
409    depth >= 0
410}
411
412/// Whether a logical module uses the exact Gleam grammar supported here.
413fn is_supported_logical_module(logical_name: &str) -> bool {
414    logical_name.split(['/', '@']).all(is_gleam_name_component)
415}
416
417fn is_gleam_name_component(component: &str) -> bool {
418    let mut bytes = component.bytes();
419    bytes.next().is_some_and(|first| first.is_ascii_lowercase())
420        && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
421}
422
423#[cfg(test)]
424#[path = "project_tests.rs"]
425mod tests;