Skip to main content

telltale_language/
effect_spec.rs

1//! Effect handler scaffolding and simulation metadata generation.
2
3use crate::ast::{Choreography, EffectAuthorityClass};
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum GeneratedEffectBehavior {
12    OneShot,
13    Stream,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum GeneratedSimulationMode {
19    Deterministic,
20    Adversarial,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct GeneratedSimulationMetadata {
25    pub behavior: GeneratedEffectBehavior,
26    pub mode: GeneratedSimulationMode,
27    pub latency_policy: String,
28    pub timeout_policy: String,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct GeneratedEffectOperation {
33    pub interface_name: String,
34    pub operation_name: String,
35    pub rust_method_name: String,
36    pub request_type_name: String,
37    pub outcome_type_name: String,
38    pub authority_class: EffectAuthorityClass,
39    pub input_type: String,
40    pub output_type: String,
41    pub simulation: GeneratedSimulationMetadata,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct GeneratedEffectFamily {
46    pub interface_name: String,
47    pub request_enum_name: String,
48    pub outcome_enum_name: String,
49    pub host_trait_name: String,
50    pub simulator_trait_name: String,
51    pub scenario_builder_name: String,
52    pub operations: Vec<GeneratedEffectOperation>,
53}
54
55/// Derive canonical effect-interface families from a parsed choreography.
56pub fn generated_effect_families(choreography: &Choreography) -> Vec<GeneratedEffectFamily> {
57    choreography
58        .effect_interface_declarations()
59        .into_iter()
60        .map(|effect| {
61            let request_enum_name = format!("{}Request", effect.name);
62            let outcome_enum_name = format!("{}Outcome", effect.name);
63            let host_trait_name = format!("{}ExternalHandler", effect.name);
64            let simulator_trait_name = format!("{}SimulatorHandler", effect.name);
65            let scenario_builder_name = format!("{}ScenarioBuilder", effect.name);
66            let operations = effect
67                .operations
68                .into_iter()
69                .map(|op| {
70                    let operation_type_name = to_upper_camel_case(&op.name);
71                    GeneratedEffectOperation {
72                        interface_name: effect.name.clone(),
73                        operation_name: op.name.clone(),
74                        rust_method_name: to_snake_case(&op.name),
75                        request_type_name: format!("{}{}Request", effect.name, operation_type_name),
76                        outcome_type_name: format!("{}{}Outcome", effect.name, operation_type_name),
77                        authority_class: op.authority_class,
78                        input_type: op.input_type,
79                        output_type: op.output_type,
80                        simulation: simulation_defaults(op.authority_class),
81                    }
82                })
83                .collect();
84
85            GeneratedEffectFamily {
86                interface_name: effect.name,
87                request_enum_name,
88                outcome_enum_name,
89                host_trait_name,
90                simulator_trait_name,
91                scenario_builder_name,
92                operations,
93            }
94        })
95        .collect()
96}
97
98/// Generate canonical Rust effect interfaces, manifests, and optional simulator
99/// scaffolds from declared effect families.
100pub fn generate_effect_interface_scaffold(
101    out_dir: &Path,
102    families: &[GeneratedEffectFamily],
103    with_simulator: bool,
104) -> Result<Vec<PathBuf>, String> {
105    fs::create_dir_all(out_dir).map_err(|e| {
106        format!(
107            "failed to create output directory '{}': {e}",
108            out_dir.display()
109        )
110    })?;
111
112    let files = build_effect_family_files(families, with_simulator)?;
113    preflight_absent_targets(out_dir, &files)?;
114    write_files_transactionally(out_dir, &files)
115}
116
117#[derive(Debug, Clone)]
118struct GeneratedFile {
119    name: &'static str,
120    kind: &'static str,
121    content: String,
122}
123
124fn build_effect_family_files(
125    families: &[GeneratedEffectFamily],
126    with_simulator: bool,
127) -> Result<Vec<GeneratedFile>, String> {
128    let mut files = vec![
129        GeneratedFile {
130            name: "generated_effects.rs",
131            kind: "generated effect interface scaffold",
132            content: render_generated_effects(families),
133        },
134        GeneratedFile {
135            name: "generated_effect_manifest.json",
136            kind: "generated effect manifest",
137            content: serde_json::to_string_pretty(families)
138                .map_err(|e| format!("encode effect manifest: {e}"))?,
139        },
140        GeneratedFile {
141            name: "README.md",
142            kind: "generated effect README",
143            content: render_generated_readme(families, with_simulator),
144        },
145    ];
146
147    if with_simulator {
148        files.push(GeneratedFile {
149            name: "generated_simulator.rs",
150            kind: "generated simulator scaffold",
151            content: render_generated_simulator(families),
152        });
153    }
154
155    Ok(files)
156}
157
158fn preflight_absent_targets(out_dir: &Path, files: &[GeneratedFile]) -> Result<(), String> {
159    for file in files {
160        let path = out_dir.join(file.name);
161        if path.exists() {
162            return Err(format!(
163                "{} already exists at '{}'; use a new output directory or remove existing files",
164                file.kind,
165                path.display()
166            ));
167        }
168    }
169    Ok(())
170}
171
172fn write_files_transactionally(
173    out_dir: &Path,
174    files: &[GeneratedFile],
175) -> Result<Vec<PathBuf>, String> {
176    let stage_dir = out_dir.join(format!(
177        ".effect_scaffold_stage_{}_{}",
178        std::process::id(),
179        now_nanos()
180    ));
181    fs::create_dir_all(&stage_dir).map_err(|e| {
182        format!(
183            "failed to create staging directory '{}': {e}",
184            stage_dir.display()
185        )
186    })?;
187
188    for file in files {
189        let stage_path = stage_dir.join(file.name);
190        if let Err(err) = fs::write(&stage_path, &file.content) {
191            drop(fs::remove_dir_all(&stage_dir));
192            return Err(format!(
193                "failed to write staging file '{}': {err}",
194                stage_path.display()
195            ));
196        }
197    }
198
199    let mut moved = Vec::new();
200    for file in files {
201        let stage_path = stage_dir.join(file.name);
202        let target_path = out_dir.join(file.name);
203        if let Err(err) = fs::rename(&stage_path, &target_path) {
204            rollback_moved_files(&moved);
205            drop(fs::remove_dir_all(&stage_dir));
206            return Err(format!(
207                "failed to finalize '{}' from staging '{}': {err}",
208                target_path.display(),
209                stage_path.display()
210            ));
211        }
212        moved.push(target_path);
213    }
214
215    if let Err(err) = fs::remove_dir(&stage_dir) {
216        return Err(format!(
217            "generated files but failed to clean staging directory '{}': {err}",
218            stage_dir.display()
219        ));
220    }
221
222    Ok(moved)
223}
224
225fn rollback_moved_files(paths: &[PathBuf]) {
226    for path in paths {
227        drop(fs::remove_file(path));
228    }
229}
230
231fn render_generated_effects(families: &[GeneratedEffectFamily]) -> String {
232    let mut out = String::from(
233        "// @generated by effect-scaffold from Telltale `effect` declarations.\n\
234         // This file is the canonical Rust-facing effect boundary for the declared interfaces.\n\n\
235         use serde::{Deserialize, Serialize};\n\
236         use serde_json::Value;\n\n",
237    );
238
239    for family in families {
240        out.push_str(&format!(
241            "#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n\
242             pub enum {} {{\n",
243            family.request_enum_name
244        ));
245        for op in &family.operations {
246            let variant_name = operation_variant_name(op);
247            out.push_str(&format!(
248                "    {}({}),\n",
249                variant_name, op.request_type_name
250            ));
251        }
252        out.push_str("}\n\n");
253
254        out.push_str(&format!(
255            "#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n\
256             pub enum {} {{\n",
257            family.outcome_enum_name
258        ));
259        for op in &family.operations {
260            let variant_name = operation_variant_name(op);
261            out.push_str(&format!(
262                "    {}({}),\n",
263                variant_name, op.outcome_type_name
264            ));
265        }
266        out.push_str("}\n\n");
267
268        for op in &family.operations {
269            out.push_str(&render_request_struct(op));
270            out.push('\n');
271            out.push_str(&render_outcome_enum(op));
272            out.push('\n');
273        }
274
275        out.push_str(&format!("pub trait {} {{\n", family.host_trait_name));
276        for op in &family.operations {
277            out.push_str(&format!(
278                "    fn {}(&self, request: {}) -> {};\n",
279                op.rust_method_name, op.request_type_name, op.outcome_type_name
280            ));
281        }
282        out.push_str("}\n\n");
283    }
284
285    out
286}
287
288fn render_generated_simulator(families: &[GeneratedEffectFamily]) -> String {
289    let mut out = String::from(
290        "// @generated by effect-scaffold from Telltale `effect` declarations.\n\
291         // This file provides first-class simulator helpers for generated effect families.\n\n\
292         use std::collections::BTreeMap;\n\n\
293         use serde_json::Value;\n\
294         use telltale_simulator::generated::{GeneratedEffectScenario, GeneratedEffectScenarioBuilder, ScenarioEffectResult};\n\n",
295    );
296
297    for family in families {
298        out.push_str(&format!(
299            "#[derive(Debug, Clone, Default)]\n\
300pub struct {}State {{\n\
301    pub values: BTreeMap<String, Value>,\n\
302    pub event_log: Vec<String>,\n\
303}}\n\n",
304            family.interface_name
305        ));
306
307        out.push_str(&format!(
308            "#[derive(Debug, Clone, Default)]\n\
309pub struct {} {{\n\
310    builder: GeneratedEffectScenarioBuilder,\n\
311}}\n\n\
312impl {} {{\n\
313    pub fn new() -> Self {{\n\
314        Self::default()\n\
315    }}\n\n",
316            family.scenario_builder_name, family.scenario_builder_name
317        ));
318        for op in &family.operations {
319            out.push_str(&render_scenario_builder_methods(family, op));
320        }
321        out.push_str(
322            "    pub fn build(self) -> GeneratedEffectScenario {\n        self.builder.build()\n    }\n}\n\n",
323        );
324
325        out.push_str(&format!("pub trait {} {{\n", family.simulator_trait_name));
326        for op in &family.operations {
327            out.push_str(&format!(
328                "    fn {}(&mut self, state: &mut {}State, request: Value) -> ScenarioEffectResult<Value>;\n",
329                op.rust_method_name, family.interface_name
330            ));
331        }
332        out.push_str("}\n\n");
333    }
334
335    out
336}
337
338fn render_generated_readme(families: &[GeneratedEffectFamily], with_simulator: bool) -> String {
339    let mut out = String::from(
340        "# Generated Effect Interfaces\n\n\
341         This directory was generated from Telltale `effect` declarations. The DSL is the single\n\
342         source of truth for the Rust host boundary, simulator scenario helpers, and exported\n\
343         effect-family manifest.\n\n\
344         ## Files\n\n\
345         - `generated_effects.rs`: canonical request/outcome enums and host-handler traits.\n\
346         - `generated_effect_manifest.json`: schema/export manifest for the same effect families.\n",
347    );
348    if with_simulator {
349        out.push_str(
350            "- `generated_simulator.rs`: first-class simulator state, traits, and scenario builders.\n",
351        );
352    }
353    out.push_str("\n## Declared effect families\n\n");
354    for family in families {
355        out.push_str(&format!("- `{}`\n", family.interface_name));
356        for op in &family.operations {
357            out.push_str(&format!(
358                "  - `{}.{}`: `{}` input, `{}` output, `{}` authority, `{}` simulation\n",
359                family.interface_name,
360                op.operation_name,
361                op.input_type,
362                op.output_type,
363                authority_class_name(op.authority_class),
364                simulation_mode_name(op.simulation.mode)
365            ));
366        }
367    }
368
369    out.push_str(
370        "\n## Next steps\n\n\
371         1. Implement the generated external-handler traits in the host runtime.\n\
372         2. Keep simulator scenarios in CI for success, timeout, cancellation, stale late result,\n\
373            blocked, and degraded cases.\n\
374         3. Treat `generated_effect_manifest.json` as the exported schema surface for this guest runtime.\n",
375    );
376    out
377}
378
379fn render_request_struct(op: &GeneratedEffectOperation) -> String {
380    format!(
381        "#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n\
382pub struct {} {{\n\
383    pub input: Value,\n\
384}}\n\n\
385impl {} {{\n\
386    pub const INTERFACE_NAME: &'static str = \"{}\";\n\
387    pub const OPERATION_NAME: &'static str = \"{}\";\n\
388    pub const DSL_INPUT_TYPE: &'static str = \"{}\";\n\
389    pub const AUTHORITY_CLASS: &'static str = \"{}\";\n\
390}}\n",
391        op.request_type_name,
392        op.request_type_name,
393        op.interface_name,
394        op.operation_name,
395        op.input_type,
396        authority_class_name(op.authority_class),
397    )
398}
399
400fn render_outcome_enum(op: &GeneratedEffectOperation) -> String {
401    format!(
402        "#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n\
403pub enum {} {{\n\
404    Return {{ value: Value }},\n\
405    Timeout,\n\
406    Cancelled,\n\
407    StaleLateResult,\n\
408    Blocked,\n\
409    Degraded {{ detail: String }},\n\
410    Error {{ value: Value }},\n\
411}}\n\n\
412impl {} {{\n\
413    pub const DSL_OUTPUT_TYPE: &'static str = \"{}\";\n\
414    pub const SIMULATION_MODE: &'static str = \"{}\";\n\
415}}\n",
416        op.outcome_type_name,
417        op.outcome_type_name,
418        op.output_type,
419        simulation_mode_name(op.simulation.mode),
420    )
421}
422
423fn render_scenario_builder_methods(
424    family: &GeneratedEffectFamily,
425    op: &GeneratedEffectOperation,
426) -> String {
427    let interface = &family.interface_name;
428    let operation = &op.operation_name;
429    let method = &op.rust_method_name;
430    format!(
431        "    pub fn {method}_success(mut self, payload: Value) -> Self {{\n\
432        self.builder = self.builder.record_return(\"{interface}\", \"{operation}\", payload);\n\
433        self\n\
434    }}\n\n\
435    pub fn {method}_timeout(mut self) -> Self {{\n\
436        self.builder = self.builder.record_timeout(\"{interface}\", \"{operation}\");\n\
437        self\n\
438    }}\n\n\
439    pub fn {method}_cancelled(mut self) -> Self {{\n\
440        self.builder = self.builder.record_cancelled(\"{interface}\", \"{operation}\");\n\
441        self\n\
442    }}\n\n\
443    pub fn {method}_stale_late_result(mut self) -> Self {{\n\
444        self.builder = self.builder.record_stale_late_result(\"{interface}\", \"{operation}\");\n\
445        self\n\
446    }}\n\n\
447    pub fn {method}_blocked(mut self) -> Self {{\n\
448        self.builder = self.builder.record_blocked(\"{interface}\", \"{operation}\");\n\
449        self\n\
450    }}\n\n\
451    pub fn {method}_degraded(mut self, detail: impl Into<String>) -> Self {{\n\
452        self.builder = self.builder.record_degraded(\"{interface}\", \"{operation}\", detail);\n\
453        self\n\
454    }}\n\n\
455    pub fn {method}_with_delay_ms(mut self, delay_ms: u64) -> Self {{\n\
456        self.builder = self.builder.with_delay_ms(delay_ms);\n\
457        self\n\
458    }}\n\n"
459    )
460}
461
462fn simulation_defaults(authority_class: EffectAuthorityClass) -> GeneratedSimulationMetadata {
463    match authority_class {
464        EffectAuthorityClass::Observe => GeneratedSimulationMetadata {
465            behavior: GeneratedEffectBehavior::Stream,
466            mode: GeneratedSimulationMode::Deterministic,
467            latency_policy: "best_effort".to_string(),
468            timeout_policy: "not_authoritative".to_string(),
469        },
470        EffectAuthorityClass::Authoritative => GeneratedSimulationMetadata {
471            behavior: GeneratedEffectBehavior::OneShot,
472            mode: GeneratedSimulationMode::Deterministic,
473            latency_policy: "bounded".to_string(),
474            timeout_policy: "required".to_string(),
475        },
476        EffectAuthorityClass::Command => GeneratedSimulationMetadata {
477            behavior: GeneratedEffectBehavior::OneShot,
478            mode: GeneratedSimulationMode::Deterministic,
479            latency_policy: "immediate".to_string(),
480            timeout_policy: "optional".to_string(),
481        },
482    }
483}
484
485fn authority_class_name(class: EffectAuthorityClass) -> &'static str {
486    match class {
487        EffectAuthorityClass::Authoritative => "authoritative",
488        EffectAuthorityClass::Command => "command",
489        EffectAuthorityClass::Observe => "observe",
490    }
491}
492
493fn operation_variant_name(op: &GeneratedEffectOperation) -> String {
494    to_upper_camel_case(&op.operation_name)
495}
496
497fn simulation_mode_name(mode: GeneratedSimulationMode) -> &'static str {
498    match mode {
499        GeneratedSimulationMode::Deterministic => "deterministic",
500        GeneratedSimulationMode::Adversarial => "adversarial",
501    }
502}
503
504fn to_snake_case(input: &str) -> String {
505    let mut out = String::with_capacity(input.len());
506    for (idx, ch) in input.chars().enumerate() {
507        if ch.is_ascii_uppercase() {
508            if idx > 0 {
509                out.push('_');
510            }
511            out.push(ch.to_ascii_lowercase());
512        } else {
513            out.push(ch);
514        }
515    }
516    out
517}
518
519fn to_upper_camel_case(input: &str) -> String {
520    let mut out = String::with_capacity(input.len());
521    let mut uppercase_next = true;
522    for ch in input.chars() {
523        if ch == '_' || ch == '-' {
524            uppercase_next = true;
525            continue;
526        }
527        if uppercase_next {
528            out.push(ch.to_ascii_uppercase());
529            uppercase_next = false;
530        } else {
531            out.push(ch);
532        }
533    }
534    out
535}
536
537fn now_nanos() -> u128 {
538    SystemTime::now()
539        .duration_since(UNIX_EPOCH)
540        .map_or(0, |duration| duration.as_nanos())
541}
542
543#[cfg(test)]
544mod tests {
545    use super::{
546        generate_effect_interface_scaffold, generated_effect_families, now_nanos,
547        render_generated_effects, render_generated_simulator, GeneratedEffectBehavior,
548    };
549    use crate::compiler::parser::parse_choreography_str;
550    use std::env;
551    use std::fs;
552    use std::path::PathBuf;
553
554    fn sample_dsl() -> &'static str {
555        r#"
556effect Runtime
557  authoritative readChannel : ChannelRef -> Result ReadError ChannelSnapshot
558  {
559    class : authoritative
560    progress : may_block
561    region : fragment
562    agreement_use : required
563    reentrancy : reject_same_fragment
564  }
565  command acceptInvite : InviteRef -> Result AcceptError MaterializedChannel
566  {
567    class : best_effort
568    progress : immediate
569    region : session
570    agreement_use : none
571    reentrancy : allow
572  }
573  observe watchPresence : ChannelId -> PresenceView
574  {
575    class : observational
576    progress : immediate
577    region : session
578    agreement_use : forbidden
579    reentrancy : allow
580  }
581
582protocol Flow uses Runtime =
583  roles Coordinator
584  Coordinator -> Coordinator : Ping
585"#
586    }
587
588    #[test]
589    fn generated_effect_families_follow_declared_effect_interfaces() {
590        let choreography = parse_choreography_str(sample_dsl()).expect("parse effect surface");
591        let families = generated_effect_families(&choreography);
592        assert_eq!(families.len(), 1);
593        let runtime = &families[0];
594        assert_eq!(runtime.interface_name, "Runtime");
595        assert_eq!(runtime.host_trait_name, "RuntimeExternalHandler");
596        assert_eq!(runtime.simulator_trait_name, "RuntimeSimulatorHandler");
597        assert_eq!(runtime.operations.len(), 3);
598        assert_eq!(runtime.operations[0].rust_method_name, "read_channel");
599        assert_eq!(
600            runtime.operations[2].simulation.behavior,
601            GeneratedEffectBehavior::Stream
602        );
603    }
604
605    #[test]
606    fn generated_effect_rendering_emits_host_and_simulator_surfaces() {
607        let choreography = parse_choreography_str(sample_dsl()).expect("parse choreography");
608        let families = generated_effect_families(&choreography);
609
610        let effects = render_generated_effects(&families);
611        assert!(effects.contains("pub enum RuntimeRequest"));
612        assert!(effects.contains("pub trait RuntimeExternalHandler"));
613        assert!(effects.contains("pub struct RuntimeReadChannelRequest"));
614        assert!(effects.contains("pub enum RuntimeWatchPresenceOutcome"));
615
616        let simulator = render_generated_simulator(&families);
617        assert!(simulator.contains("pub struct RuntimeScenarioBuilder"));
618        assert!(simulator.contains("pub trait RuntimeSimulatorHandler"));
619        assert!(simulator.contains("read_channel_success"));
620    }
621
622    #[test]
623    fn scaffold_generation_writes_expected_files() {
624        let out_dir = unique_temp_dir("effect_scaffold_ok");
625        let choreography = parse_choreography_str(sample_dsl()).expect("parse choreography");
626        let generated = generate_effect_interface_scaffold(
627            &out_dir,
628            &generated_effect_families(&choreography),
629            true,
630        )
631        .expect("generation succeeds");
632
633        assert_eq!(generated.len(), 4);
634        assert!(out_dir.join("generated_effects.rs").exists());
635        assert!(out_dir.join("generated_effect_manifest.json").exists());
636        assert!(out_dir.join("generated_simulator.rs").exists());
637        assert!(out_dir.join("README.md").exists());
638        let effects = fs::read_to_string(out_dir.join("generated_effects.rs")).expect("read");
639        assert!(effects.contains("RuntimeExternalHandler"));
640        let simulator =
641            fs::read_to_string(out_dir.join("generated_simulator.rs")).expect("read sim");
642        assert!(simulator.contains("RuntimeScenarioBuilder"));
643
644        drop(fs::remove_dir_all(out_dir));
645    }
646
647    #[test]
648    fn preflight_rejects_existing_files_without_partial_writes() {
649        let out_dir = unique_temp_dir("effect_scaffold_preflight");
650        fs::create_dir_all(&out_dir).expect("create out dir");
651        fs::write(
652            out_dir.join("generated_effect_manifest.json"),
653            "already here",
654        )
655        .expect("seed existing file");
656        let choreography = parse_choreography_str(sample_dsl()).expect("parse choreography");
657
658        let error = generate_effect_interface_scaffold(
659            &out_dir,
660            &generated_effect_families(&choreography),
661            true,
662        )
663        .expect_err("preflight should fail");
664        assert!(error.contains("generated_effect_manifest.json"));
665        assert!(!out_dir.join("generated_effects.rs").exists());
666        assert!(!out_dir.join("generated_simulator.rs").exists());
667        assert!(!out_dir.join("README.md").exists());
668
669        drop(fs::remove_dir_all(out_dir));
670    }
671
672    fn unique_temp_dir(prefix: &str) -> PathBuf {
673        let mut path = env::temp_dir();
674        path.push(format!("{prefix}_{}_{}", std::process::id(), now_nanos()));
675        path
676    }
677}