#[cfg(test)]
mod tests {
use etdl_parser::ast::EtlDocument;
use std::collections::BTreeMap;
use std::path::PathBuf;
fn fixture_path(filename: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(filename)
}
#[test]
fn test_parse_full_worked_example() {
let path = fixture_path("order-fulfillment.etdl");
let doc = etdl_parser::parse_document_from_file(&path).unwrap();
assert_eq!(doc.etdl, "1.0.0");
assert_eq!(doc.info.title, "Order Fulfillment Event Tree");
assert_eq!(doc.info.domain, "FulfillmentContext");
assert_eq!(doc.asyncapi_imports.len(), 2);
assert!(doc.asyncapi_imports.contains_key("orders_api"));
assert!(doc.asyncapi_imports.contains_key("payment_api"));
assert_eq!(doc.event_trees.len(), 1);
let tree = &doc.event_trees["OrderFulfillment"];
assert_eq!(tree.initiating_event.id, "OrderPlacedTrigger");
match &tree.initiating_event.message {
etdl_parser::ast::MessageRef::External(ext_ref) => {
assert_eq!(ext_ref.alias, "orders_api");
assert_eq!(ext_ref.pointer, "#/components/messages/OrderPlaced");
}
other => panic!("expected an External Message Reference, got {other:?}"),
}
assert_eq!(tree.nodes.len(), 5);
let barrier = match &tree.nodes["InventoryCheckBarrier"] {
etdl_parser::ast::Node::Barrier(b) => b,
_ => panic!("expected barrier"),
};
assert_eq!(barrier.branches.len(), 2);
assert_eq!(barrier.branches[0].outcome, "SUCCESS");
assert!(matches!(
&barrier.branches[0].condition,
etdl_parser::ast::Condition::Expr(_)
));
assert!(matches!(
&barrier.branches[1].condition,
etdl_parser::ast::Condition::Default
));
let op = match &tree.nodes["ProcessPaymentOperation"] {
etdl_parser::ast::Node::Operation(op) => op,
_ => panic!("expected operation"),
};
assert_eq!(op.handler, "stripe_charge_handler");
assert!(op.retry_policy.is_some());
let retry = op.retry_policy.as_ref().unwrap();
assert_eq!(retry.max_attempts, 3);
assert_eq!(op.timeout_ms, Some(5000));
assert!(op.on_failure.is_some());
assert!(op.on_failure_probability_source.is_some());
let fault_trees = doc.fault_trees.as_ref().unwrap();
assert_eq!(fault_trees.len(), 1);
let ft = &fault_trees["PaymentGatewayFailure"];
assert_eq!(ft.top_event.id, "PaymentCaptureFailed");
let gates = ft.gates.as_ref().unwrap();
assert_eq!(gates.len(), 1);
let gate = &gates["GatewayUnavailableOrRejected"];
assert!(matches!(gate.gate_type, etdl_parser::ast::GateType::Or));
assert_eq!(gate.inputs.len(), 2);
assert_eq!(ft.basic_events.len(), 2);
let be1 = &ft.basic_events["GatewayUnreachable"];
assert_eq!(be1.probability, Some(0.008));
let be2 = &ft.basic_events["ChargeRejected"];
assert_eq!(be2.failure_rate, Some(0.00021));
assert_eq!(be2.mission_time, Some(24.0));
}
#[test]
fn test_asyncapi_loading() {
let base_dir = fixture_path("");
let doc_path = fixture_path("order-fulfillment.etdl");
let doc = etdl_parser::parse_document_from_file(&doc_path).unwrap();
let mut registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
for (alias, location) in &doc.asyncapi_imports {
registry.load(alias, location, &base_dir).unwrap();
}
let message_ref = &doc.event_trees["OrderFulfillment"].initiating_event.message;
let resolved = registry.resolve_message(&doc, message_ref).unwrap();
assert!(resolved.get("payload").is_some());
let payload = &resolved["payload"];
assert_eq!(payload["type"], "object");
}
#[test]
fn test_compile_worked_example() {
let base_dir = fixture_path("");
let doc_path = fixture_path("order-fulfillment.etdl");
let doc = etdl_parser::parse_document_from_file(&doc_path).unwrap();
let registry = etdl_parser::load_asyncapi_imports(&doc, &base_dir).unwrap();
let compiler = etdl_compiler::Compiler::new();
let result = compiler.compile(&doc, ®istry);
assert!(
result.rust_output.is_some(),
"Compilation should produce Rust code. Diagnostics: {:?}",
result.diagnostics
);
let rust_code = result.rust_output.unwrap();
println!("=== GENERATED RUST CODE ===\n{}\n=== END ===", rust_code);
assert!(rust_code.contains("AUTOGENERATED BY ETDL COMPILER"));
assert!(rust_code.contains("handle_order_placed_trigger"));
assert!(rust_code.contains("BranchMonitor::new"));
assert!(rust_code.contains("stripe_charge_handler"));
assert!(rust_code.contains("record_branch"));
assert!(
rust_code.contains("InventoryCheckBarrier"),
"Generated code should reference InventoryCheckBarrier"
);
let errors: Vec<_> = result.diagnostics.iter().filter(|d| d.is_error()).collect();
assert!(
errors.is_empty(),
"Compilation should have no errors, got: {:?}",
errors
);
}
#[test]
fn test_ecel_parsing() {
use etdl_parser::ecel::*;
let cond = parse_condition("message.payload.items[*].qty > 0").unwrap();
match cond {
Condition::Expr(BoolExpr::Comparison(cmp)) => {
assert_eq!(cmp.op, Comparator::Gt);
match &cmp.left {
Operand::Value(ValueExpr::Path(path)) => {
assert_eq!(path.segments.len(), 5);
}
_ => panic!("expected path"),
}
match &cmp.right {
Operand::Value(ValueExpr::Number(n)) => assert_eq!(*n, 0.0),
_ => panic!("expected number"),
}
}
_ => panic!("expected comparison"),
}
}
#[test]
fn test_fault_tree_probability() {
let _base_dir = fixture_path("");
let doc_path = fixture_path("order-fulfillment.etdl");
let doc = etdl_parser::parse_document_from_file(&doc_path).unwrap();
let mut diagnostics = Vec::new();
let probs = etdl_compiler::fault_tree::resolve_fault_trees(&doc, &mut diagnostics);
assert!(
diagnostics.iter().all(|d| !d.is_error()),
"Fault tree resolution should have no errors: {:?}",
diagnostics
);
let payment_failure_prob = probs.get("PaymentGatewayFailure").unwrap();
let charge_rejected_prob = 1.0 - (-0.00021_f64 * 24.0).exp();
let expected = 1.0 - (1.0 - 0.008) * (1.0 - charge_rejected_prob);
assert!(
(payment_failure_prob - expected).abs() < 0.00001,
"Expected ~{:.6}, got {:.6}",
expected,
payment_failure_prob
);
}
#[test]
fn test_validation_detects_errors() {
let yaml = r#"
etdl: "1.0.0"
info:
title: "Bad Tree"
version: "1.0.0"
domain: "Test"
asyncapi_imports: {}
eventTrees:
Bad:
initiatingEvent:
id: Test
message: "bad#/foo"
next: MissingNode
nodes: {}
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let mut diagnostics = Vec::new();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diagnostics);
let errors: Vec<_> = diagnostics.iter().filter(|d| d.is_error()).collect();
assert!(!errors.is_empty(), "Should detect errors in bad document");
assert!(
errors
.iter()
.any(|d| d.code == "E-103" || d.code == "V-101"),
"Should have reference or structural errors, got: {:?}",
errors
);
}
#[test]
fn test_advanced_fault_tree_features() {
use etdl_parser::ast::{BasicEventType, GateType};
let path = fixture_path("advanced-fault-tree.etdl");
let doc = etdl_parser::parse_document_from_file(&path).unwrap();
let ft = &doc.fault_trees.as_ref().unwrap()["AdvancedTree"];
let gates = ft.gates.as_ref().unwrap();
assert_eq!(gates["InhibitedFailure"].gate_type, GateType::Inhibit);
assert_eq!(
gates["InhibitedFailure"].inhibit_condition.as_deref(),
Some("Control loop armed")
);
assert_eq!(gates["PriorityFailure"].gate_type, GateType::PriorityAnd);
assert_eq!(
ft.basic_events["ExternalCause"].event_type,
Some(BasicEventType::House)
);
assert_eq!(
ft.basic_events["UnderAnalyzed"].event_type,
Some(BasicEventType::Undeveloped)
);
assert_eq!(ft.basic_events["EventA"].event_type, None);
let transfers = ft.transfers.as_ref().unwrap();
assert_eq!(
transfers["SubAnalysis"].target,
"#/faultTrees/OtherTree/topEvent"
);
assert_eq!(
transfers["SubAnalysis"].label.as_deref(),
Some("See sub-analysis")
);
let mut diags = Vec::new();
let probs = etdl_compiler::fault_tree::resolve_fault_trees(&doc, &mut diags);
let advanced = probs["AdvancedTree"];
let inhibit = 0.1 * 0.5;
let priority = (0.2 * 0.3) / 2.0;
let expected = 1.0 - (1.0 - inhibit) * (1.0 - priority) * (1.0 - 0.01) * (1.0 - 0.02);
assert!(
(advanced - expected).abs() < 0.00001,
"Expected ~{:.6}, got {:.6}",
expected,
advanced
);
let errors: Vec<_> = diags.iter().filter(|d| d.is_error()).collect();
assert!(
errors.is_empty(),
"advanced fixture should have no errors: {:?}",
errors
);
}
#[test]
fn test_inhibit_gate_requires_condition() {
use etdl_parser::ast::GateType;
let yaml = r#"
etdl: "1.0.0"
info:
title: "Inhibit Missing Condition"
version: "1.0.0"
domain: "Test"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: Trig
message: "api#/m"
next: C
nodes:
C:
type: consequence
operation: terminate
faultTrees:
F:
topEvent:
id: Top
description: "top"
rootCause: G
gates:
G:
type: INHIBIT
inputs: [A, B]
basicEvents:
A:
description: "a"
probability: 0.1
B:
description: "b"
probability: 0.2
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let mut diags = Vec::new();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diags);
assert!(
diags.iter().any(|d| d.code == "V-505"),
"INHIBIT without inhibitCondition should produce V-505, got {:?}",
diags
);
let ft = &doc.fault_trees.as_ref().unwrap()["F"];
assert_eq!(ft.gates.as_ref().unwrap()["G"].gate_type, GateType::Inhibit);
}
#[test]
fn test_validation_diagnostics_carry_span_keys() {
use etdl_parser::spanned::{build_span_index, SpanKey};
let yaml = r#"
etdl: "1.0.0"
info:
title: "Bad"
version: "1.0.0"
domain: "D"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: I
message: "a#/m"
next: MissingNode
nodes:
Present:
type: consequence
operation: terminate
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let mut diags = Vec::new();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diags);
let v101 = diags
.iter()
.find(|d| d.code == "V-101")
.expect("V-101 present");
let key = v101.key.as_ref().expect("diagnostic carries a span key");
assert_eq!(
key,
&SpanKey::InitiatingEvent {
tree: "T".to_string(),
field: "next",
}
);
let index = build_span_index(yaml).unwrap();
let el = index.resolve(key).expect("key resolves");
let span = el.key_span.unwrap_or(el.span);
assert_eq!(span.line, 12);
assert!(span.column > 0);
}
#[test]
fn test_duplicate_node_ids_warn() {
let yaml = r#"
etdl: "1.0.0"
info:
title: "Dup"
version: "1.0.0"
domain: "D"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: I
message: "a#/m"
next: N
nodes:
N:
type: consequence
operation: terminate
N:
type: consequence
operation: terminate
"#;
let dups = etdl_parser::spanned::detect_duplicate_ids(yaml).unwrap();
assert_eq!(dups.len(), 1);
assert_eq!(dups[0].id, "N");
assert_eq!(dups[0].kind, "node");
assert_eq!(dups[0].span.line, 17);
}
#[test]
fn test_language_version_major_gate() {
let yaml = r#"
etdl: "2.0.0"
info:
title: "Future"
version: "1.0.0"
domain: "D"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: I
message: "a#/m"
next: N
nodes:
N:
type: consequence
operation: terminate
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let mut diags = Vec::new();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diags);
assert!(
diags.iter().any(|d| d.code == "E-100"),
"future major version must be rejected with E-100, got {:?}",
diags
);
}
#[test]
fn test_handler_identifier_validation() {
let yaml = r#"
etdl: "1.0.0"
info:
title: "Handler"
version: "1.0.0"
domain: "D"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: I
message: "a#/m"
next: O
nodes:
O:
type: operation
action: execute
handler: "not a valid id!"
next: C
C:
type: consequence
operation: terminate
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let mut diags = Vec::new();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diags);
assert!(
diags.iter().any(|d| d.code == "V-301"),
"invalid handler should produce V-301, got {:?}",
diags
);
}
#[test]
fn test_non_terminating_path_is_v104() {
let yaml = r#"
etdl: "1.0.0"
info:
title: "NoTerminal"
version: "1.0.0"
domain: "D"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: I
message: "a#/m"
next: O1
nodes:
O1:
type: operation
action: execute
handler: "h1"
next: O2
O2:
type: operation
action: execute
handler: "h2"
next: O2
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let mut diags = Vec::new();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diags);
assert!(
diags.iter().any(|d| d.code == "V-104"),
"non-terminating path should produce V-104, got {:?}",
diags
);
}
#[test]
fn test_branch_probability_range_and_sum() {
let yaml = r#"
etdl: "1.0.0"
info:
title: "BranchSum"
version: "1.0.0"
domain: "D"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: I
message: "a#/m"
next: B
nodes:
B:
type: barrier
branches:
- outcome: SUCCESS
condition: "message.payload.ok == true"
probability: 0.9
next: C
- outcome: FAILURE
condition: default
probability: 0.2
next: C
C:
type: consequence
operation: terminate
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
let mut diags = Vec::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diags);
let resolved =
etdl_compiler::validate::resolve_probability_links(&doc, &BTreeMap::new(), &mut diags);
etdl_compiler::validate::validate_probability_sums(&doc, &resolved, &mut diags);
assert!(
diags.iter().any(|d| d.code == "V-203"),
"branch probabilities 0.9+0.2 must fail V-203, got {:?}",
diags
);
}
#[test]
fn test_consequence_revisit_not_cycle() {
let yaml = r#"
etdl: "1.0.0"
info:
title: "Revisit"
version: "1.0.0"
domain: "D"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: I
message: "a#/m"
next: B
nodes:
B:
type: barrier
branches:
- outcome: SUCCESS
condition: default
probability: 0.5
next: C
- outcome: FAILURE
condition: default
probability: 0.5
next: C
C:
type: consequence
operation: terminate
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let mut diags = Vec::new();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diags);
assert!(
!diags.iter().any(|d| d.code == "V-102"),
"revisiting a consequence must not be flagged as a cycle, got {:?}",
diags
);
}
#[test]
fn test_transfer_target_must_exist() {
let yaml = r##"
etdl: "1.0.0"
info:
title: "Transfer"
version: "1.0.0"
domain: "D"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: I
message: "a#/m"
next: C
nodes:
C:
type: consequence
operation: terminate
faultTrees:
F:
topEvent:
id: Top
description: "top"
rootCause: E
basicEvents:
E:
description: "e"
probability: 0.01
transfers:
Gone:
target: "#/faultTrees/DoesNotExist/topEvent"
label: "see other"
"##;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let mut diags = Vec::new();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diags);
assert!(
diags.iter().any(|d| d.code == "V-506"),
"transfer to a missing fault tree should produce V-506, got {:?}",
diags
);
}
fn run_cli(args: &[&str]) -> (std::process::Output, String) {
let bin = env!("CARGO_BIN_EXE_etdl");
let out = std::process::Command::new(bin)
.args(args)
.output()
.expect("cli runs");
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
(out, stdout)
}
#[test]
fn cli_validate_exit_zero_on_valid() {
let (out, _) = run_cli(&[
"validate",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn cli_validate_exit_one_on_invalid() {
let dir = std::env::temp_dir();
let bad = dir.join("etdl_cli_bad_validate.etdl");
std::fs::write(&bad, "etdl: \"2.0.0\"\n").unwrap();
let (out, _) = run_cli(&["validate", bad.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(1));
let _ = std::fs::remove_file(&bad);
}
#[test]
fn cli_validate_json_output() {
let (out, stdout) = run_cli(&[
"validate",
"--json",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0));
let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
assert_eq!(v["results"][0]["valid"], serde_json::json!(true));
}
#[test]
fn cli_analyze_json_output() {
let (out, stdout) = run_cli(&[
"analyze",
"--json",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0));
let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
assert_eq!(v["eventTrees"], serde_json::json!(1));
assert_eq!(v["faultTrees"], serde_json::json!(1));
}
#[test]
fn cli_version() {
let (out, stdout) = run_cli(&["--version"]);
assert_eq!(out.status.code(), Some(0));
assert!(stdout.contains("etdl "));
}
#[test]
fn cli_capabilities_json_reports_supplements_from_their_own_descriptors() {
let (out, stdout) = run_cli(&["capabilities", "--json"]);
assert_eq!(out.status.code(), Some(0));
let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let extensions = v["extensions"].as_array().expect("extensions array");
let performance = extensions
.iter()
.find(|e| e["id"] == "etdl.performance")
.expect("etdl.performance listed");
assert!(
!performance["summary"].as_str().unwrap_or_default().is_empty(),
"expected a non-empty summary sourced from PerformanceExtension::descriptor(), got {performance}"
);
assert_eq!(performance["schema"], "etdl.performance/1.0");
assert!(performance["diagnostic_codes"]
.as_array()
.expect("diagnostic_codes array")
.iter()
.any(|c| c == "E-161"));
let security = extensions
.iter()
.find(|e| e["id"] == "etdl.security")
.expect("etdl.security listed");
assert_eq!(
security["requires"],
serde_json::json!(["etdl.tree-event"]),
"expected SecurityExtension::descriptor()'s cross-supplement dependency to surface, got {security}"
);
}
fn temp_out_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"etdl_cli_target_{}_{}",
name,
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn cli_compile_default_target_is_rust() {
let out_dir = temp_out_dir("default");
let (out, _) = run_cli(&[
"compile",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
"--out-dir",
out_dir.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0));
assert!(out_dir.join("order-fulfillment.rs").exists());
let _ = std::fs::remove_dir_all(&out_dir);
}
#[test]
fn cli_compile_explicit_rust_matches_default_output() {
let default_dir = temp_out_dir("explicit_rust_default");
let explicit_dir = temp_out_dir("explicit_rust_explicit");
run_cli(&[
"compile",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
"--out-dir",
default_dir.to_str().unwrap(),
]);
run_cli(&[
"compile",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
"--target",
"rust",
"--out-dir",
explicit_dir.to_str().unwrap(),
]);
let default_src = std::fs::read_to_string(default_dir.join("order-fulfillment.rs")).unwrap();
let explicit_src = std::fs::read_to_string(explicit_dir.join("order-fulfillment.rs")).unwrap();
assert_eq!(default_src, explicit_src, "bare `compile` and `--target rust` must produce byte-identical output");
let _ = std::fs::remove_dir_all(&default_dir);
let _ = std::fs::remove_dir_all(&explicit_dir);
}
#[test]
fn cli_compile_unknown_target_fails_with_a_clear_error() {
let out_dir = temp_out_dir("unknown");
let (out, _) = run_cli(&[
"compile",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
"--target",
"cobol",
"--out-dir",
out_dir.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("unsupported target 'cobol'"), "got: {stderr}");
assert!(stderr.contains("available targets:"), "got: {stderr}");
assert!(!out_dir.exists(), "unknown target must fail before writing anything");
}
#[test]
fn cli_compile_help_lists_available_targets() {
let (out, stdout) = run_cli(&["compile", "--help"]);
assert_eq!(out.status.code(), Some(0));
assert!(stdout.contains("Available in this build:"), "got: {stdout}");
assert!(stdout.contains("rust"), "got: {stdout}");
}
#[cfg(feature = "target-java")]
#[test]
fn cli_compile_target_java_generates_a_compilable_package() {
let out_dir = temp_out_dir("java");
let (out, stdout) = run_cli(&[
"compile",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
"--target",
"java",
"--out-dir",
out_dir.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0));
assert!(stdout.contains("target 'java'"), "got: {stdout}");
assert!(out_dir.join("etdl/runtime/WorkflowError.java").exists());
assert!(out_dir.join("fulfillmentcontext/OrderFulfillmentWorkflow.java").exists());
let _ = std::fs::remove_dir_all(&out_dir);
}
#[cfg(feature = "target-python")]
#[test]
fn cli_compile_target_python_generates_expected_modules() {
let out_dir = temp_out_dir("python");
let (out, stdout) = run_cli(&[
"compile",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
"--target",
"python",
"--out-dir",
out_dir.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0));
assert!(stdout.contains("target 'python'"), "got: {stdout}");
assert!(out_dir.join("etdl/runtime/branch_monitor.py").exists());
assert!(out_dir.join("fulfillment_context/workflow.py").exists());
let _ = std::fs::remove_dir_all(&out_dir);
}
#[cfg(feature = "target-go")]
#[test]
fn cli_compile_target_go_generates_expected_module() {
let out_dir = temp_out_dir("go");
let (out, stdout) = run_cli(&[
"compile",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
"--target",
"go",
"--out-dir",
out_dir.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0));
assert!(stdout.contains("target 'go'"), "got: {stdout}");
assert!(out_dir.join("go.mod").exists());
assert!(out_dir.join("etdl/runtime/branch_monitor.go").exists());
assert!(out_dir.join("fulfillmentcontext/workflow.go").exists());
let _ = std::fs::remove_dir_all(&out_dir);
}
#[cfg(feature = "target-dotnet")]
#[test]
fn cli_compile_target_dotnet_generates_expected_project() {
let out_dir = temp_out_dir("dotnet");
let (out, stdout) = run_cli(&[
"compile",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
"--target",
"dotnet",
"--out-dir",
out_dir.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0));
assert!(stdout.contains("target 'dotnet'"), "got: {stdout}");
assert!(out_dir.join("OrderFulfillment.csproj").exists());
assert!(out_dir.join("Etdl/Runtime/BranchMonitor.cs").exists());
assert!(out_dir.join("FulfillmentContext/OrderFulfillmentWorkflow.cs").exists());
let _ = std::fs::remove_dir_all(&out_dir);
}
#[cfg(feature = "target-java")]
#[test]
fn cli_compile_multi_target_writes_both_outputs() {
let out_dir = temp_out_dir("multi");
let (out, _) = run_cli(&[
"compile",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
"--target",
"rust,java",
"--out-dir",
out_dir.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0));
assert!(out_dir.join("order-fulfillment.rs").exists());
assert!(out_dir.join("fulfillmentcontext/OrderFulfillmentWorkflow.java").exists());
let _ = std::fs::remove_dir_all(&out_dir);
}
#[cfg(all(
feature = "target-java",
feature = "target-python",
feature = "target-go",
feature = "target-dotnet"
))]
#[test]
fn cli_compile_all_five_targets_in_one_invocation() {
let out_dir = temp_out_dir("all_five");
let (out, _) = run_cli(&[
"compile",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
"--target",
"rust,java,python,go,dotnet",
"--out-dir",
out_dir.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0));
assert!(out_dir.join("order-fulfillment.rs").exists());
assert!(out_dir.join("fulfillmentcontext/OrderFulfillmentWorkflow.java").exists());
assert!(out_dir.join("fulfillment_context/workflow.py").exists());
assert!(out_dir.join("fulfillmentcontext/workflow.go").exists());
assert!(out_dir.join("FulfillmentContext/OrderFulfillmentWorkflow.cs").exists());
let _ = std::fs::remove_dir_all(&out_dir);
}
#[cfg(feature = "plugins")]
mod supplement_plugins {
use super::*;
use std::path::Path;
fn wasm_fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("etdl-compiler")
.join("tests")
.join("fixtures")
.join("wasm-plugins")
.join(name)
}
fn scratch_home() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"etdl-supplement-cli-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn run_cli_with_home(home: &Path, args: &[&str]) -> (std::process::Output, String) {
let bin = env!("CARGO_BIN_EXE_etdl");
let out = std::process::Command::new(bin)
.args(args)
.env("HOME", home)
.output()
.expect("cli runs");
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
(out, stdout)
}
#[test]
fn install_list_remove_round_trip() {
let home = scratch_home();
let (out, stdout) = run_cli_with_home(&home, &["supplement", "list"]);
assert_eq!(out.status.code(), Some(0));
assert!(stdout.contains("(none)"), "expected no plugins yet, got: {stdout}");
let (out, stdout) = run_cli_with_home(
&home,
&["supplement", "install", wasm_fixture("valid.wasm").to_str().unwrap()],
);
assert_eq!(out.status.code(), Some(0), "install failed: {stdout}");
assert!(stdout.contains("etdl.fixture-valid"));
let (out, stdout) = run_cli_with_home(&home, &["supplement", "list"]);
assert_eq!(out.status.code(), Some(0));
assert!(stdout.contains("etdl.fixture-valid"), "got: {stdout}");
let (out, stdout) =
run_cli_with_home(&home, &["supplement", "remove", "etdl.fixture-valid"]);
assert_eq!(out.status.code(), Some(0), "remove failed: {stdout}");
let (out, stdout) = run_cli_with_home(&home, &["supplement", "list"]);
assert_eq!(out.status.code(), Some(0));
assert!(stdout.contains("(none)"), "expected removed, got: {stdout}");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn non_conforming_module_is_rejected_at_install_time() {
let home = scratch_home();
let bad_file = home.join("not-a-plugin.wasm");
std::fs::write(&bad_file, b"this is not a wasm module").unwrap();
let (out, stdout) = run_cli_with_home(
&home,
&["supplement", "install", bad_file.to_str().unwrap()],
);
assert_eq!(out.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("not a conforming supplement plugin"),
"got stdout={stdout} stderr={stderr}"
);
let (out, stdout) = run_cli_with_home(&home, &["supplement", "list"]);
assert_eq!(out.status.code(), Some(0));
assert!(stdout.contains("(none)"), "rejected module must not be installed: {stdout}");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn installed_plugin_diagnostic_surfaces_in_validate() {
let home = scratch_home();
let (out, _) = run_cli_with_home(
&home,
&["supplement", "install", wasm_fixture("valid.wasm").to_str().unwrap()],
);
assert_eq!(out.status.code(), Some(0));
let doc_dir = home.join("doc");
std::fs::create_dir_all(&doc_dir).unwrap();
std::fs::write(
doc_dir.join("api.yaml"),
r#"
asyncapi: "3.0.0"
info: { title: "Demo", version: "1.0.0" }
channels:
requests:
address: requests
messages:
Request: { payload: { type: object } }
components:
messages:
Request: { payload: { type: object } }
"#,
)
.unwrap();
std::fs::write(
doc_dir.join("doc.etdl"),
r#"
etdl: "1.0.0"
info: { title: "T", version: "1.0.0", domain: "D" }
asyncapi_imports:
api: "./api.yaml"
supplements:
- id: "etdl.fixture-valid"
version: "1.0"
eventTrees:
Flow:
initiatingEvent: { id: I, message: "api#/components/messages/Request", next: C }
nodes:
C: { type: consequence, operation: terminate }
"#,
)
.unwrap();
let (out, stdout) = run_cli_with_home(
&home,
&["validate", doc_dir.join("doc.etdl").to_str().unwrap()],
);
assert_eq!(out.status.code(), Some(0), "got: {stdout}");
assert!(
stdout.contains("FIXTURE-001") && stdout.contains("etdl.fixture-valid"),
"expected the plugin's diagnostic to surface, got: {stdout}"
);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn zero_installed_plugins_is_a_true_no_op() {
let home = scratch_home();
let (out, stdout) = run_cli_with_home(
&home,
&[
"validate",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
],
);
assert_eq!(out.status.code(), Some(0), "got: {stdout}");
assert!(stdout.contains("is valid"));
let _ = std::fs::remove_dir_all(&home);
}
}
}