1use 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#[derive(Debug)]
32pub struct ActivityArtifact {
33 pub path: PathBuf,
35 pub relative: String,
37 pub contents: String,
39}
40
41#[derive(Debug)]
43pub struct ActivityReport {
44 pub artifacts: Vec<ActivityArtifact>,
46 pub written: bool,
48}
49
50#[derive(Debug)]
52pub struct CodecReport {
53 pub module_relative: String,
55 pub written: bool,
57}
58
59pub 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
99pub 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#[derive(Debug)]
139pub struct TestScaffoldReport {
140 pub module_relative: String,
142 pub mocked_activities: usize,
144 pub timer_advances: usize,
146 pub written: bool,
148}
149
150pub 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
219fn 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
233fn resolve<'a>(
236 declarations: &'a [ActivityDeclaration],
237 schemas: &'a [SchemaArtifact],
238) -> Result<Vec<ResolvedActivity<'a>>, CodegenError> {
239 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
265fn 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
291fn 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 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 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
350fn 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
362fn 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
379fn 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 generate_codecs(&root, CodegenMode::Check)?;
468 generate_activities(&root, &declarations, CodegenMode::Check)?;
469
470 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 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}