1use 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
55pub 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
98pub 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}