use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::activity_model::{ResolvedActivity, ResolvedType};
use super::declaration::{ActivityDeclaration, Tier};
use super::error::CodegenError;
use super::project::{CodegenMode, check_on_disk, parse_project_schemas, read_package_name};
use super::schema::{GleamType, SchemaArtifact};
use super::test_scaffold::{self, WorkflowTestFacts};
use super::{activity_golden, activity_worker_python, activity_worker_rust, activity_wrappers};
use crate::structure::extract_workflow_facts;
#[derive(Debug)]
pub struct ActivityArtifact {
pub path: PathBuf,
pub relative: String,
pub contents: String,
}
#[derive(Debug)]
pub struct ActivityReport {
pub artifacts: Vec<ActivityArtifact>,
pub written: bool,
}
#[derive(Debug)]
pub struct CodecReport {
pub module_relative: String,
pub written: bool,
}
pub fn generate_codecs(root: &Path, mode: CodegenMode) -> Result<CodecReport, CodegenError> {
let package_name = read_package_name(root)?;
let schemas = parse_project_schemas(root)?;
let relative = format!("src/{package_name}_codecs.gleam");
let artifact = ActivityArtifact {
path: root.join(&relative),
relative: relative.clone(),
contents: activity_wrappers::emit_codecs_module(&package_name, &schemas),
};
let written = match mode {
CodegenMode::Write => {
write_artifact(&artifact)?;
true
}
CodegenMode::Check => {
check_on_disk(&artifact.path, &artifact.contents)?;
false
}
};
Ok(CodecReport {
module_relative: relative,
written,
})
}
pub fn generate_activities(
root: &Path,
declarations: &[ActivityDeclaration],
mode: CodegenMode,
) -> Result<ActivityReport, CodegenError> {
let package_name = read_package_name(root)?;
let schemas = parse_project_schemas(root)?;
let resolved = resolve(declarations, &schemas)?;
let artifacts = build_artifacts(root, &package_name, &resolved)?;
let written = match mode {
CodegenMode::Write => {
for artifact in &artifacts {
write_artifact(artifact)?;
}
true
}
CodegenMode::Check => {
for artifact in &artifacts {
check_on_disk(&artifact.path, &artifact.contents)?;
}
false
}
};
Ok(ActivityReport { artifacts, written })
}
#[derive(Debug)]
pub struct TestScaffoldReport {
pub module_relative: String,
pub mocked_activities: usize,
pub timer_advances: usize,
pub written: bool,
}
pub fn generate_test_scaffold(
root: &Path,
entry_module: &str,
declarations: &[ActivityDeclaration],
mode: CodegenMode,
) -> Result<TestScaffoldReport, CodegenError> {
let package_name = read_package_name(root)?;
let schemas = parse_project_schemas(root)?;
let resolved = resolve(declarations, &schemas)?;
let source_path = root.join("src").join(format!("{entry_module}.gleam"));
let source =
std::fs::read_to_string(&source_path).map_err(|source| CodegenError::EntrySourceRead {
path: source_path.clone(),
source,
})?;
let facts = extract_workflow_facts(&source).map_err(|error| CodegenError::ScaffoldFacts {
path: source_path.clone(),
reason: error.to_string(),
})?;
let test_facts = WorkflowTestFacts {
entry_module,
entry_function: &facts.typed_entry_function,
timer_count: facts.timer_count,
};
let contents = test_scaffold::emit_scaffold_module(&package_name, &test_facts, &resolved);
let relative = format!("test/{entry_module}_scaffold_test.gleam");
let artifact = ActivityArtifact {
path: root.join(&relative),
relative: relative.clone(),
contents,
};
let written = match mode {
CodegenMode::Write => {
write_artifact(&artifact)?;
true
}
CodegenMode::Check => {
check_on_disk(&artifact.path, &artifact.contents)?;
false
}
};
Ok(TestScaffoldReport {
module_relative: relative,
mocked_activities: resolved.len(),
timer_advances: facts.timer_count,
written,
})
}
fn write_artifact(artifact: &ActivityArtifact) -> Result<(), CodegenError> {
if let Some(parent) = artifact.path.parent() {
std::fs::create_dir_all(parent).map_err(|source| CodegenError::Write {
path: artifact.path.clone(),
source,
})?;
}
std::fs::write(&artifact.path, &artifact.contents).map_err(|source| CodegenError::Write {
path: artifact.path.clone(),
source,
})
}
fn resolve<'a>(
declarations: &'a [ActivityDeclaration],
schemas: &'a [SchemaArtifact],
) -> Result<Vec<ResolvedActivity<'a>>, CodegenError> {
let mut by_type: HashMap<&str, (&SchemaArtifact, &str)> = HashMap::with_capacity(schemas.len());
for artifact in schemas {
if let GleamType::Named {
type_name,
fn_prefix,
} = &artifact.root
{
by_type.insert(type_name.as_str(), (artifact, fn_prefix.as_str()));
}
}
let mut resolved = Vec::with_capacity(declarations.len());
for declaration in declarations {
let input = resolve_type(declaration, "input", &declaration.input_type, &by_type)?;
let output = resolve_type(declaration, "output", &declaration.output_type, &by_type)?;
resolved.push(ResolvedActivity {
declaration,
input,
output,
});
}
Ok(resolved)
}
fn resolve_type<'a>(
declaration: &ActivityDeclaration,
role: &'static str,
type_name: &str,
by_type: &HashMap<&str, (&'a SchemaArtifact, &str)>,
) -> Result<ResolvedType<'a>, CodegenError> {
let (artifact, fn_prefix) =
by_type
.get(type_name)
.ok_or_else(|| CodegenError::ActivitySchemaMissing {
activity: declaration.name.clone(),
role,
type_name: type_name.to_owned(),
path: PathBuf::from(format!(
"schemas/{}.json",
super::names::pascal_to_snake(type_name)
)),
})?;
Ok(ResolvedType {
gleam_type: type_name.to_owned(),
fn_prefix: (*fn_prefix).to_owned(),
artifact,
})
}
fn build_artifacts(
root: &Path,
package_name: &str,
resolved: &[ResolvedActivity],
) -> Result<Vec<ActivityArtifact>, CodegenError> {
let src = root.join("src");
let mut artifacts = Vec::new();
artifacts.push(gleam_module(
&src,
package_name,
"activity_wrappers",
activity_wrappers::emit_wrappers_module(package_name, resolved),
));
let python = of_tier(resolved, Tier::RemotePython);
let rust = of_tier(resolved, Tier::RemoteRust);
if !python.is_empty() {
artifacts.push(file(
root,
"worker/worker.py".to_owned(),
activity_worker_python::emit(package_name, &python),
));
}
if !rust.is_empty() {
artifacts.push(file(
root,
"worker/src/main.rs".to_owned(),
activity_worker_rust::emit(package_name, &rust),
));
}
let remote: Vec<&ResolvedActivity> = resolved
.iter()
.filter(|a| a.declaration.tier.is_remote())
.collect();
if !remote.is_empty() {
artifacts.push(file(
root,
format!("test/{package_name}_wire_compat_test.gleam"),
activity_golden::emit(package_name, &remote)?,
));
}
Ok(artifacts)
}
fn of_tier<'a, 'b>(
resolved: &'b [ResolvedActivity<'a>],
tier: Tier,
) -> Vec<&'b ResolvedActivity<'a>> {
resolved
.iter()
.filter(|a| a.declaration.tier == tier)
.collect()
}
fn gleam_module(
src: &Path,
package_name: &str,
suffix: &str,
contents: String,
) -> ActivityArtifact {
let file_name = format!("{package_name}_{suffix}.gleam");
let relative = format!("src/{file_name}");
ActivityArtifact {
path: src.join(file_name),
relative,
contents,
}
}
fn file(root: &Path, relative: String, contents: String) -> ActivityArtifact {
ActivityArtifact {
path: root.join(&relative),
relative,
contents,
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::PathBuf;
use super::{generate_activities, generate_codecs};
use crate::codegen::declaration::{ActivityDeclaration, Tier};
use crate::codegen::error::CodegenError;
use crate::codegen::project::CodegenMode;
use crate::project::fixture;
type TestResult = Result<(), Box<dyn std::error::Error>>;
const GLEAM_TOML: &str = "name = \"demo\"\nversion = \"0.1.0\"\ntarget = \"erlang\"\n";
const ORDER_SCHEMA: &[u8] = br#"{
"type": "object",
"required": ["order_id", "amount"],
"additionalProperties": false,
"properties": {
"order_id": { "type": "string" },
"amount": { "type": "integer" }
}
}"#;
const RECEIPT_SCHEMA: &[u8] = br#"{
"type": "object",
"required": ["payment_id"],
"additionalProperties": false,
"properties": { "payment_id": { "type": "string" } }
}"#;
fn project(label: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
fixture::temp_project(
label,
&[
("gleam.toml", GLEAM_TOML.as_bytes()),
("schemas/order.json", ORDER_SCHEMA),
("schemas/receipt.json", RECEIPT_SCHEMA),
],
)
}
fn declaration(input: &str, output: &str) -> ActivityDeclaration {
ActivityDeclaration {
name: "charge".to_owned(),
tier: Tier::RemotePython,
input_type: input.to_owned(),
output_type: output.to_owned(),
}
}
#[test]
fn write_then_check_round_trips_and_detects_drift() -> TestResult {
let root = project("activity-write")?;
let declarations = [declaration("Order", "Receipt")];
let codecs = generate_codecs(&root, CodegenMode::Write)?;
assert!(codecs.written);
assert!(root.join("src/demo_codecs.gleam").is_file());
let report = generate_activities(&root, &declarations, CodegenMode::Write)?;
assert!(report.written);
let relatives: Vec<&str> = report
.artifacts
.iter()
.map(|artifact| artifact.relative.as_str())
.collect();
assert_eq!(
relatives,
vec![
"src/demo_activity_wrappers.gleam",
"worker/worker.py",
"test/demo_wire_compat_test.gleam",
]
);
for artifact in &report.artifacts {
assert!(artifact.path.is_file(), "{} not written", artifact.relative);
}
generate_codecs(&root, CodegenMode::Check)?;
generate_activities(&root, &declarations, CodegenMode::Check)?;
let wrappers = root.join("src/demo_activity_wrappers.gleam");
let mut tampered = fs::read_to_string(&wrappers)?;
tampered.push_str("\n// hand edit\n");
fs::write(&wrappers, &tampered)?;
let result = generate_activities(&root, &declarations, CodegenMode::Check);
let Err(CodegenError::CheckDrift { path }) = result else {
fs::remove_dir_all(&root)?;
return Err(format!("expected CheckDrift, got {result:?}").into());
};
assert_eq!(path, wrappers);
fs::remove_dir_all(&root)?;
Ok(())
}
#[test]
fn in_vm_tier_emits_neither_worker_nor_golden() -> TestResult {
let root = project("activity-invm")?;
let declarations = [ActivityDeclaration {
name: "charge".to_owned(),
tier: Tier::InVm,
input_type: "Order".to_owned(),
output_type: "Receipt".to_owned(),
}];
let report = generate_activities(&root, &declarations, CodegenMode::Write)?;
let relatives: Vec<&str> = report
.artifacts
.iter()
.map(|artifact| artifact.relative.as_str())
.collect();
assert_eq!(relatives, vec!["src/demo_activity_wrappers.gleam"]);
assert!(!root.join("worker/worker.py").exists());
assert!(!root.join("test/demo_wire_compat_test.gleam").exists());
fs::remove_dir_all(&root)?;
Ok(())
}
#[test]
fn declared_type_without_a_schema_errors() -> TestResult {
let root = project("activity-missing")?;
let declarations = [declaration("Order", "NoSuchType")];
let result = generate_activities(&root, &declarations, CodegenMode::Write);
let Err(CodegenError::ActivitySchemaMissing {
activity,
role,
type_name,
..
}) = result
else {
fs::remove_dir_all(&root)?;
return Err(format!("expected ActivitySchemaMissing, got {result:?}").into());
};
assert_eq!(activity, "charge");
assert_eq!(role, "output");
assert_eq!(type_name, "NoSuchType");
fs::remove_dir_all(&root)?;
Ok(())
}
}