1use 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#[derive(Debug)]
35pub struct ActivityArtifact {
36 pub path: PathBuf,
38 pub relative: String,
40 pub contents: String,
42}
43
44#[derive(Debug)]
46pub struct ActivityReport {
47 pub artifacts: Vec<ActivityArtifact>,
49 pub written: bool,
51}
52
53#[derive(Debug)]
55pub struct CodecReport {
56 pub module_relative: String,
58 pub written: bool,
60}
61
62pub 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
104pub 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#[derive(Debug)]
144pub struct TestScaffoldReport {
145 pub module_relative: String,
147 pub mocked_activities: usize,
149 pub timer_advances: usize,
151 pub written: bool,
154}
155
156pub 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 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
236fn 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
250fn 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
293fn 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
317fn 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 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 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
376fn 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
388fn 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
405fn 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 generate_codecs(&root, &types, CodegenMode::Check)?;
511 generate_activities(&root, &declarations, &types, CodegenMode::Check)?;
512
513 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 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}