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