Skip to main content

aion_package/codegen/
activity_project.rs

1//! The activity-plumbing generator (`aion generate`).
2//!
3//! Given a package's parsed activity declarations (extracted from its typed
4//! Gleam `manifest()` by the CLI — this library never spawns a process) and
5//! its boundary-type model (mapped from the exported package interface by
6//! [`super::interface`]), this module resolves each declared value type to its
7//! boundary type and emits the plumbing that used to be hand-mirrored across
8//! five-to-seven files: the codecs module, the typed activity wrappers, the
9//! per-tier worker handler stubs and registration, and the wire-compat golden.
10//!
11//! Generation is a pure, deterministic function of the declarations and the
12//! model: declaration order and field order are preserved, names come from
13//! pure helpers, and nothing reads the wall clock or iterates a `HashMap`
14//! into output. The whole artifact set is rendered before any file is
15//! touched, so a resolution error leaves the tree untouched, and a
16//! delete-all-and-regenerate round-trip is byte-identical.
17//! [`CodegenMode::Check`] re-renders and byte-compares instead of writing,
18//! for CI drift gates.
19
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22
23use super::activity_model::{ResolvedActivity, ResolvedType};
24use super::codec_module;
25use super::declaration::{ActivityDeclaration, Tier};
26use super::error::CodegenError;
27use super::model::{BoundaryType, GleamType};
28use super::project::{CodegenMode, check_on_disk, read_package_name};
29use super::test_scaffold::{self, WorkflowTestFacts};
30use super::{activity_golden, activity_worker_python, activity_worker_rust, activity_wrappers};
31use crate::structure::extract_workflow_facts;
32
33/// One generated file: its path and fully-rendered contents.
34#[derive(Debug)]
35pub struct ActivityArtifact {
36    /// Absolute path the file is written to or checked against.
37    pub path: PathBuf,
38    /// Project-root-relative path, for the CLI's JSON report.
39    pub relative: String,
40    /// The fully-rendered file contents, built before any write.
41    pub contents: String,
42}
43
44/// The result of generating (or checking) a package's activity plumbing.
45#[derive(Debug)]
46pub struct ActivityReport {
47    /// Every generated file, in deterministic order.
48    pub artifacts: Vec<ActivityArtifact>,
49    /// Whether the files were written (`Write`) or only checked (`Check`).
50    pub written: bool,
51}
52
53/// The result of generating (or checking) a package's codecs module.
54#[derive(Debug)]
55pub struct CodecReport {
56    /// The generated codecs module path, relative to the project root.
57    pub module_relative: String,
58    /// Whether the module was written (`Write`) or only checked (`Check`).
59    pub written: bool,
60}
61
62/// Generates (or, in [`CodegenMode::Check`], verifies) the package's codecs
63/// module `src/<package>_codecs.gleam` from its boundary types.
64///
65/// The codecs are derived from the types module alone — not the activity
66/// declarations — so this runs *before* the package's `manifest()` is
67/// executed to extract the declarations: the author's activities module
68/// references `codecs.<type>_codec()`, which must already compile for the
69/// extraction build to succeed. [`generate_activities`] emits the remaining
70/// plumbing (wrappers, worker, golden) once the declarations are known.
71///
72/// # Errors
73///
74/// Returns a [`CodegenError`] for an unreadable `gleam.toml`, a write
75/// failure, or — in check mode — a missing or drifted codecs module.
76pub fn generate_codecs(
77    root: &Path,
78    types: &[BoundaryType],
79    mode: CodegenMode,
80) -> Result<CodecReport, CodegenError> {
81    let package_name = read_package_name(root)?;
82    let relative = format!("src/{package_name}_codecs.gleam");
83    let artifact = ActivityArtifact {
84        path: root.join(&relative),
85        relative: relative.clone(),
86        contents: codec_module::emit_codecs_module(&package_name, types),
87    };
88    let written = match mode {
89        CodegenMode::Write => {
90            write_artifact(&artifact)?;
91            true
92        }
93        CodegenMode::Check => {
94            check_on_disk(&artifact.path, &artifact.contents)?;
95            false
96        }
97    };
98    Ok(CodecReport {
99        module_relative: relative,
100        written,
101    })
102}
103
104/// Generates (or, in [`CodegenMode::Check`], verifies) the activity plumbing
105/// for the package at `root` from its `declarations` and boundary `types`.
106///
107/// # Errors
108///
109/// Returns a [`CodegenError`] for an unreadable `gleam.toml`, a declared
110/// value type not present in the types module
111/// ([`CodegenError::ActivityTypeMissing`]), a write failure, or — in check
112/// mode — a missing or drifted generated file. No file is written if
113/// rendering any artifact fails.
114pub fn generate_activities(
115    root: &Path,
116    declarations: &[ActivityDeclaration],
117    types: &[BoundaryType],
118    mode: CodegenMode,
119) -> Result<ActivityReport, CodegenError> {
120    let package_name = read_package_name(root)?;
121    let resolved = resolve(&package_name, declarations, types)?;
122    let artifacts = build_artifacts(root, &package_name, &resolved)?;
123
124    let written = match mode {
125        CodegenMode::Write => {
126            for artifact in &artifacts {
127                write_artifact(artifact)?;
128            }
129            true
130        }
131        CodegenMode::Check => {
132            for artifact in &artifacts {
133                check_on_disk(&artifact.path, &artifact.contents)?;
134            }
135            false
136        }
137    };
138
139    Ok(ActivityReport { artifacts, written })
140}
141
142/// The result of generating (or checking) a workflow's `aion/testing` skeleton.
143#[derive(Debug)]
144pub struct TestScaffoldReport {
145    /// The scaffold module path, relative to the project root.
146    pub module_relative: String,
147    /// The number of activities pre-mocked in a freshly-written scaffold.
148    pub mocked_activities: usize,
149    /// The number of clock advances scaffolded (one per durable timer).
150    pub timer_advances: usize,
151    /// Whether the module was written (`false` when it already existed or in
152    /// check mode).
153    pub written: bool,
154}
155
156/// Generates the `aion/testing` skeleton
157/// `test/<entry_module>_scaffold_test.gleam` for the workflow whose typed
158/// entry lives in `src/<entry_module>.gleam`.
159///
160/// Unlike the do-not-edit artifacts, the scaffold is written **once**: it is
161/// a fill-in starting point the author owns after generation, so a write run
162/// leaves an existing scaffold untouched, and [`CodegenMode::Check`] only
163/// requires the file to exist (never byte-compares it against a fresh
164/// render, which would flag every filled-in `todo` as drift).
165///
166/// # Errors
167///
168/// Returns a [`CodegenError`] for an unreadable `gleam.toml`, an unresolved
169/// declared value type, an unreadable or facts-less entry-module source, a
170/// write failure, or — in check mode — a missing scaffold.
171pub fn generate_test_scaffold(
172    root: &Path,
173    entry_module: &str,
174    declarations: &[ActivityDeclaration],
175    types: &[BoundaryType],
176    mode: CodegenMode,
177) -> Result<TestScaffoldReport, CodegenError> {
178    let package_name = read_package_name(root)?;
179    let resolved = resolve(&package_name, declarations, types)?;
180
181    let source_path = root.join("src").join(format!("{entry_module}.gleam"));
182    let source =
183        std::fs::read_to_string(&source_path).map_err(|source| CodegenError::EntrySourceRead {
184            path: source_path.clone(),
185            source,
186        })?;
187    let facts = extract_workflow_facts(&source).map_err(|error| CodegenError::ScaffoldFacts {
188        path: source_path.clone(),
189        reason: error.to_string(),
190    })?;
191
192    let relative = format!("test/{entry_module}_scaffold_test.gleam");
193    let path = root.join(&relative);
194    if mode == CodegenMode::Check {
195        if !path.is_file() {
196            return Err(CodegenError::CheckMissing { path });
197        }
198        return Ok(TestScaffoldReport {
199            module_relative: relative,
200            mocked_activities: resolved.len(),
201            timer_advances: facts.timer_count,
202            written: false,
203        });
204    }
205    if path.is_file() {
206        // The author owns a scaffold once it exists; never clobber their
207        // filled-in test.
208        return Ok(TestScaffoldReport {
209            module_relative: relative,
210            mocked_activities: resolved.len(),
211            timer_advances: facts.timer_count,
212            written: false,
213        });
214    }
215
216    let test_facts = WorkflowTestFacts {
217        entry_module,
218        entry_function: &facts.typed_entry_function,
219        timer_count: facts.timer_count,
220    };
221    let artifact = ActivityArtifact {
222        path,
223        relative: relative.clone(),
224        contents: test_scaffold::emit_scaffold_module(&package_name, &test_facts, &resolved),
225    };
226    write_artifact(&artifact)?;
227
228    Ok(TestScaffoldReport {
229        module_relative: relative,
230        mocked_activities: resolved.len(),
231        timer_advances: facts.timer_count,
232        written: true,
233    })
234}
235
236/// Writes one artifact, creating any missing parent directories first.
237fn write_artifact(artifact: &ActivityArtifact) -> Result<(), CodegenError> {
238    if let Some(parent) = artifact.path.parent() {
239        std::fs::create_dir_all(parent).map_err(|source| CodegenError::Write {
240            path: artifact.path.clone(),
241            source,
242        })?;
243    }
244    std::fs::write(&artifact.path, &artifact.contents).map_err(|source| CodegenError::Write {
245        path: artifact.path.clone(),
246        source,
247    })
248}
249
250/// Resolves each declaration's input and output value types to the boundary
251/// types that generate their codecs, preserving declaration order.
252fn resolve<'a>(
253    package_name: &str,
254    declarations: &'a [ActivityDeclaration],
255    types: &'a [BoundaryType],
256) -> Result<Vec<ResolvedActivity<'a>>, CodegenError> {
257    let mut by_type: HashMap<&str, (&BoundaryType, &str)> = HashMap::with_capacity(types.len());
258    for boundary in types {
259        if let GleamType::Named {
260            type_name,
261            fn_prefix,
262        } = &boundary.root
263        {
264            by_type.insert(type_name.as_str(), (boundary, fn_prefix.as_str()));
265        }
266    }
267
268    let mut resolved = Vec::with_capacity(declarations.len());
269    for declaration in declarations {
270        let input = resolve_type(
271            package_name,
272            declaration,
273            "input",
274            &declaration.input_type,
275            &by_type,
276        )?;
277        let output = resolve_type(
278            package_name,
279            declaration,
280            "output",
281            &declaration.output_type,
282            &by_type,
283        )?;
284        resolved.push(ResolvedActivity {
285            declaration,
286            input,
287            output,
288        });
289    }
290    Ok(resolved)
291}
292
293/// Resolves one declared value type name to its boundary type.
294fn resolve_type<'a>(
295    package_name: &str,
296    declaration: &ActivityDeclaration,
297    role: &'static str,
298    type_name: &str,
299    by_type: &HashMap<&str, (&'a BoundaryType, &str)>,
300) -> Result<ResolvedType<'a>, CodegenError> {
301    let (boundary, fn_prefix) =
302        by_type
303            .get(type_name)
304            .ok_or_else(|| CodegenError::ActivityTypeMissing {
305                activity: declaration.name.clone(),
306                role,
307                type_name: type_name.to_owned(),
308                module: format!("{package_name}_io"),
309            })?;
310    Ok(ResolvedType {
311        gleam_type: type_name.to_owned(),
312        fn_prefix: (*fn_prefix).to_owned(),
313        boundary,
314    })
315}
316
317/// Renders every generated activity artifact in a deterministic order: the
318/// typed wrappers module first, then a worker plumbing module and (for remote
319/// tiers) a wire-compat golden per non-empty tier. The codecs module is owned
320/// by [`generate_codecs`] (it is types-driven and must precede declaration
321/// extraction), so it is not re-emitted here.
322fn build_artifacts(
323    root: &Path,
324    package_name: &str,
325    resolved: &[ResolvedActivity],
326) -> Result<Vec<ActivityArtifact>, CodegenError> {
327    let src = root.join("src");
328    let mut artifacts = Vec::new();
329
330    artifacts.push(gleam_module(
331        &src,
332        package_name,
333        "activity_wrappers",
334        activity_wrappers::emit_wrappers_module(package_name, resolved),
335    ));
336
337    // In-VM activities execute as the author's Gleam body referenced by the
338    // generated wrapper; they have no separate worker artifact and never cross
339    // the wire, so they get neither a worker stub nor a golden.
340    let python = of_tier(resolved, Tier::RemotePython);
341    let rust = of_tier(resolved, Tier::RemoteRust);
342
343    if !python.is_empty() {
344        artifacts.push(file(
345            root,
346            "worker/worker.py".to_owned(),
347            activity_worker_python::emit(package_name, &python),
348        ));
349    }
350    if !rust.is_empty() {
351        artifacts.push(file(
352            root,
353            "worker/src/main.rs".to_owned(),
354            activity_worker_rust::emit(package_name, &rust),
355        ));
356    }
357
358    // One wire-compat golden covering every remote activity, in declaration
359    // order: a Gleam SDK-side test that pins each value type's encoded wire
360    // shape against a literal derived from the type, worker-language-agnostic.
361    let remote: Vec<&ResolvedActivity> = resolved
362        .iter()
363        .filter(|a| a.declaration.tier.is_remote())
364        .collect();
365    if !remote.is_empty() {
366        artifacts.push(file(
367            root,
368            format!("test/{package_name}_wire_compat_test.gleam"),
369            activity_golden::emit(package_name, &remote)?,
370        ));
371    }
372
373    Ok(artifacts)
374}
375
376/// Collects references to the resolved activities of one tier, in declaration
377/// order.
378fn of_tier<'a, 'b>(
379    resolved: &'b [ResolvedActivity<'a>],
380    tier: Tier,
381) -> Vec<&'b ResolvedActivity<'a>> {
382    resolved
383        .iter()
384        .filter(|a| a.declaration.tier == tier)
385        .collect()
386}
387
388/// Builds an artifact for a generated Gleam module `src/<pkg>_<suffix>.gleam`.
389/// `src` is the package's absolute `src/` directory.
390fn gleam_module(
391    src: &Path,
392    package_name: &str,
393    suffix: &str,
394    contents: String,
395) -> ActivityArtifact {
396    let file_name = format!("{package_name}_{suffix}.gleam");
397    let relative = format!("src/{file_name}");
398    ActivityArtifact {
399        path: src.join(file_name),
400        relative,
401        contents,
402    }
403}
404
405/// Builds an artifact for a generated file at a project-root-relative path.
406fn file(root: &Path, relative: String, contents: String) -> ActivityArtifact {
407    ActivityArtifact {
408        path: root.join(&relative),
409        relative,
410        contents,
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use std::fs;
417    use std::path::PathBuf;
418
419    use super::{generate_activities, generate_codecs};
420    use crate::codegen::declaration::{ActivityDeclaration, Tier};
421    use crate::codegen::error::CodegenError;
422    use crate::codegen::model::{BoundaryType, Field, GleamType, RecordDef, TypeDef};
423    use crate::codegen::project::CodegenMode;
424    use crate::project::fixture;
425
426    type TestResult = Result<(), Box<dyn std::error::Error>>;
427
428    const GLEAM_TOML: &str = "name = \"demo\"\nversion = \"0.1.0\"\ntarget = \"erlang\"\n";
429
430    fn boundary(type_name: &str, fields: Vec<(&str, GleamType, bool)>) -> BoundaryType {
431        let stem = crate::codegen::names::pascal_to_snake(type_name);
432        BoundaryType {
433            file: PathBuf::from(format!("schemas/{stem}.json")),
434            stem: stem.clone(),
435            root: GleamType::Named {
436                type_name: type_name.to_owned(),
437                fn_prefix: stem.clone(),
438            },
439            defs: vec![TypeDef::Record(RecordDef {
440                type_name: type_name.to_owned(),
441                fn_prefix: stem,
442                fields: fields
443                    .into_iter()
444                    .map(|(wire, ty, required)| Field {
445                        wire: wire.to_owned(),
446                        ty,
447                        required,
448                    })
449                    .collect(),
450            })],
451        }
452    }
453
454    fn model() -> Vec<BoundaryType> {
455        vec![
456            boundary(
457                "Order",
458                vec![
459                    ("order_id", GleamType::String, true),
460                    ("amount", GleamType::Int, true),
461                ],
462            ),
463            boundary("Receipt", vec![("payment_id", GleamType::String, true)]),
464        ]
465    }
466
467    fn project(label: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
468        fixture::temp_project(label, &[("gleam.toml", GLEAM_TOML.as_bytes())])
469    }
470
471    fn declaration(input: &str, output: &str) -> ActivityDeclaration {
472        ActivityDeclaration {
473            name: "charge".to_owned(),
474            tier: Tier::RemotePython,
475            input_type: input.to_owned(),
476            output_type: output.to_owned(),
477        }
478    }
479
480    #[test]
481    fn write_then_check_round_trips_and_detects_drift() -> TestResult {
482        let root = project("activity-write")?;
483        let types = model();
484        let declarations = [declaration("Order", "Receipt")];
485
486        let codecs = generate_codecs(&root, &types, CodegenMode::Write)?;
487        assert!(codecs.written);
488        assert!(root.join("src/demo_codecs.gleam").is_file());
489
490        let report = generate_activities(&root, &declarations, &types, CodegenMode::Write)?;
491        assert!(report.written);
492        let relatives: Vec<&str> = report
493            .artifacts
494            .iter()
495            .map(|artifact| artifact.relative.as_str())
496            .collect();
497        assert_eq!(
498            relatives,
499            vec![
500                "src/demo_activity_wrappers.gleam",
501                "worker/worker.py",
502                "test/demo_wire_compat_test.gleam",
503            ]
504        );
505        for artifact in &report.artifacts {
506            assert!(artifact.path.is_file(), "{} not written", artifact.relative);
507        }
508
509        // A clean tree passes --check.
510        generate_codecs(&root, &types, CodegenMode::Check)?;
511        generate_activities(&root, &declarations, &types, CodegenMode::Check)?;
512
513        // A hand-edit to a generated file is caught.
514        let wrappers = root.join("src/demo_activity_wrappers.gleam");
515        let mut tampered = fs::read_to_string(&wrappers)?;
516        tampered.push_str("\n// hand edit\n");
517        fs::write(&wrappers, &tampered)?;
518        let result = generate_activities(&root, &declarations, &types, CodegenMode::Check);
519        let Err(CodegenError::CheckDrift { path }) = result else {
520            fs::remove_dir_all(&root)?;
521            return Err(format!("expected CheckDrift, got {result:?}").into());
522        };
523        assert_eq!(path, wrappers);
524
525        fs::remove_dir_all(&root)?;
526        Ok(())
527    }
528
529    #[test]
530    fn in_vm_tier_emits_neither_worker_nor_golden() -> TestResult {
531        let root = project("activity-invm")?;
532        let types = model();
533        let declarations = [ActivityDeclaration {
534            name: "charge".to_owned(),
535            tier: Tier::InVm,
536            input_type: "Order".to_owned(),
537            output_type: "Receipt".to_owned(),
538        }];
539
540        let report = generate_activities(&root, &declarations, &types, CodegenMode::Write)?;
541        let relatives: Vec<&str> = report
542            .artifacts
543            .iter()
544            .map(|artifact| artifact.relative.as_str())
545            .collect();
546        // Only the typed wrappers; an in-VM activity never crosses the wire.
547        assert_eq!(relatives, vec!["src/demo_activity_wrappers.gleam"]);
548        assert!(!root.join("worker/worker.py").exists());
549        assert!(!root.join("test/demo_wire_compat_test.gleam").exists());
550
551        fs::remove_dir_all(&root)?;
552        Ok(())
553    }
554
555    #[test]
556    fn declared_type_without_a_boundary_type_errors() -> TestResult {
557        let root = project("activity-missing")?;
558        let types = model();
559        let declarations = [declaration("Order", "NoSuchType")];
560
561        let result = generate_activities(&root, &declarations, &types, CodegenMode::Write);
562        let Err(CodegenError::ActivityTypeMissing {
563            activity,
564            role,
565            type_name,
566            module,
567        }) = result
568        else {
569            fs::remove_dir_all(&root)?;
570            return Err(format!("expected ActivityTypeMissing, got {result:?}").into());
571        };
572        assert_eq!(activity, "charge");
573        assert_eq!(role, "output");
574        assert_eq!(type_name, "NoSuchType");
575        assert_eq!(module, "demo_io");
576
577        fs::remove_dir_all(&root)?;
578        Ok(())
579    }
580}