etdl-cli 0.1.3

ETDL CLI: compile and validate .etdl documents with IEC 61025 fault tree and IEC 62502 event tree analysis, generating Rust for microservices
#[cfg(test)]
mod tests {
    use etdl_parser::ast::EtlDocument;
    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");
        assert_eq!(tree.initiating_event.message.alias, "orders_api");
        assert_eq!(
            tree.initiating_event.message.pointer,
            "#/components/messages/OrderPlaced"
        );

        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::Comparison(_)
            )
        );
        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_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, &registry);

        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::Comparison(cmp) => {
                assert_eq!(cmp.op, Comparator::Gt);
                match &cmp.left {
                    Operand::Path(path) => {
                        assert_eq!(path.segments.len(), 5);
                    }
                    _ => panic!("expected path"),
                }
                match &cmp.right {
                    Operand::Literal(Literal::Number(n)) => assert_eq!(*n, 0.0),
                    _ => panic!("expected number literal"),
                }
            }
            _ => 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, &registry, &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"];

        // New gate types parse.
        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
        );

        // eventType parses.
        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);

        // transfers parse.
        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")
        );

        // Probability resolution: INHIBIT = 0.1*0.5 = 0.05,
        // PRIORITY_AND = (0.2*0.3)/2! = 0.03, OR over them and the leaves.
        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, &registry, &mut diags);

        assert!(
            diags.iter().any(|d| d.code == "V-505"),
            "INHIBIT without inhibitCondition should produce V-505, got {:?}",
            diags
        );

        // Sanity: gate type parsed as Inhibit.
        let ft = &doc.fault_trees.as_ref().unwrap()["F"];
        assert_eq!(ft.gates.as_ref().unwrap()["G"].gate_type, GateType::Inhibit);
    }
}