From c7658be205e20d6cc739b3c3694424b78180d8b8 Mon Sep 17 00:00:00 2001 From: rita-aga Date: Tue, 24 Mar 2026 15:51:04 -0400 Subject: [PATCH 1/3] Refactor temper-spec for guideline compliance --- crates/temper-spec/src/automaton/lint.rs | 541 +++------- crates/temper-spec/src/automaton/lint_test.rs | 292 ++++++ crates/temper-spec/src/automaton/parser.rs | 591 +---------- .../temper-spec/src/automaton/parser_test.rs | 10 + .../src/automaton/parser_test_core.rs | 112 +++ .../src/automaton/parser_test_features.rs | 196 ++++ .../src/automaton/parser_test_integrations.rs | 248 +++++ .../src/automaton/parser_test_triggers.rs | 41 + .../temper-spec/src/automaton/toml_parser.rs | 925 ------------------ .../src/automaton/toml_parser/effects.rs | 152 +++ .../src/automaton/toml_parser/guards.rs | 246 +++++ .../src/automaton/toml_parser/inline.rs | 186 ++++ .../src/automaton/toml_parser/mod.rs | 368 +++++++ .../test.rs} | 13 + crates/temper-spec/src/automaton/translate.rs | 235 +---- .../src/automaton/translate_test.rs | 231 +++++ crates/temper-spec/src/automaton/types.rs | 303 +----- .../temper-spec/src/automaton/types_test.rs | 299 ++++++ crates/temper-spec/src/csdl/merge.rs | 194 ++-- crates/temper-spec/src/csdl/parser.rs | 678 ------------- .../temper-spec/src/csdl/parser/elements.rs | 221 +++++ crates/temper-spec/src/csdl/parser/mod.rs | 56 ++ crates/temper-spec/src/csdl/parser/schema.rs | 281 ++++++ crates/temper-spec/src/csdl/parser/test.rs | 116 +++ crates/temper-spec/src/csdl/parser/xml.rs | 54 + crates/temper-spec/src/csdl/types.rs | 221 +---- crates/temper-spec/src/csdl/types_test.rs | 228 +++++ crates/temper-spec/src/model/mod.rs | 201 ++-- crates/temper-spec/src/tlaplus/extractor.rs | 664 ------------- .../temper-spec/src/tlaplus/extractor/mod.rs | 49 + .../src/tlaplus/extractor/properties.rs | 125 +++ .../src/tlaplus/extractor/source.rs | 94 ++ .../src/tlaplus/extractor/states.rs | 80 ++ .../temper-spec/src/tlaplus/extractor/test.rs | 120 +++ .../src/tlaplus/extractor/transitions.rs | 189 ++++ 35 files changed, 4392 insertions(+), 4168 deletions(-) create mode 100644 crates/temper-spec/src/automaton/lint_test.rs create mode 100644 crates/temper-spec/src/automaton/parser_test.rs create mode 100644 crates/temper-spec/src/automaton/parser_test_core.rs create mode 100644 crates/temper-spec/src/automaton/parser_test_features.rs create mode 100644 crates/temper-spec/src/automaton/parser_test_integrations.rs create mode 100644 crates/temper-spec/src/automaton/parser_test_triggers.rs delete mode 100644 crates/temper-spec/src/automaton/toml_parser.rs create mode 100644 crates/temper-spec/src/automaton/toml_parser/effects.rs create mode 100644 crates/temper-spec/src/automaton/toml_parser/guards.rs create mode 100644 crates/temper-spec/src/automaton/toml_parser/inline.rs create mode 100644 crates/temper-spec/src/automaton/toml_parser/mod.rs rename crates/temper-spec/src/automaton/{toml_parser_tests.rs => toml_parser/test.rs} (92%) create mode 100644 crates/temper-spec/src/automaton/translate_test.rs create mode 100644 crates/temper-spec/src/automaton/types_test.rs delete mode 100644 crates/temper-spec/src/csdl/parser.rs create mode 100644 crates/temper-spec/src/csdl/parser/elements.rs create mode 100644 crates/temper-spec/src/csdl/parser/mod.rs create mode 100644 crates/temper-spec/src/csdl/parser/schema.rs create mode 100644 crates/temper-spec/src/csdl/parser/test.rs create mode 100644 crates/temper-spec/src/csdl/parser/xml.rs create mode 100644 crates/temper-spec/src/csdl/types_test.rs delete mode 100644 crates/temper-spec/src/tlaplus/extractor.rs create mode 100644 crates/temper-spec/src/tlaplus/extractor/mod.rs create mode 100644 crates/temper-spec/src/tlaplus/extractor/properties.rs create mode 100644 crates/temper-spec/src/tlaplus/extractor/source.rs create mode 100644 crates/temper-spec/src/tlaplus/extractor/states.rs create mode 100644 crates/temper-spec/src/tlaplus/extractor/test.rs create mode 100644 crates/temper-spec/src/tlaplus/extractor/transitions.rs diff --git a/crates/temper-spec/src/automaton/lint.rs b/crates/temper-spec/src/automaton/lint.rs index 9611fb6b..428624ff 100644 --- a/crates/temper-spec/src/automaton/lint.rs +++ b/crates/temper-spec/src/automaton/lint.rs @@ -150,102 +150,167 @@ pub fn lint_automata_bundle(automata: &BTreeMap) -> Vec = - action.params.iter().cloned().collect(); - available_params.insert("parent_id".to_string()); - available_params.insert("parent_type".to_string()); - available_params.insert(format!("{parent_snake}_id")); - - let missing_params: Vec = target_action - .params - .iter() - .filter(|param| !available_params.contains(*param)) - .cloned() - .collect(); - - if !missing_params.is_empty() { - let available: Vec = available_params.into_iter().collect(); - findings.push(BundleLintFinding::error( - entity_name.clone(), - "spawn_initial_action_params_unmapped", - format!( - "action '{}' spawns '{}' -> '{}'; missing params {:?}, available params {:?}", - action.name, - entity_type, - initial_action_name, - missing_params, - available - ), - )); - } + lint_spawn_effect( + automata, + entity_name, + &parent_snake, + action, + effect, + &mut findings, + ); } } } + sort_bundle_findings(&mut findings); + findings +} + +fn lint_spawn_effect( + automata: &BTreeMap, + entity_name: &str, + parent_snake: &str, + action: &super::Action, + effect: &Effect, + findings: &mut Vec, +) { + let Effect::Spawn { + entity_type, + initial_action, + .. + } = effect + else { + return; + }; + + let Some(target_automaton) = automata.get(entity_type) else { + findings.push(BundleLintFinding::error( + entity_name.to_string(), + "spawn_target_missing", + format!( + "action '{}' spawns unknown entity type '{}'", + action.name, entity_type + ), + )); + return; + }; + + let Some(initial_action_name) = initial_action.as_deref() else { + return; + }; + + let Some(target_action) = target_action(target_automaton, initial_action_name) else { + findings.push(BundleLintFinding::error( + entity_name.to_string(), + "spawn_initial_action_missing", + format!( + "action '{}' spawns '{}' with missing initial_action '{}'", + action.name, entity_type, initial_action_name + ), + )); + return; + }; + + lint_spawn_initial_state( + entity_name, + action, + entity_type, + initial_action_name, + target_automaton, + target_action, + findings, + ); + lint_spawn_param_mapping( + entity_name, + parent_snake, + action, + entity_type, + initial_action_name, + target_action, + findings, + ); +} + +fn target_action<'a>(automaton: &'a Automaton, action_name: &str) -> Option<&'a super::Action> { + automaton + .actions + .iter() + .find(|candidate| candidate.name == action_name) +} + +fn lint_spawn_initial_state( + entity_name: &str, + action: &super::Action, + entity_type: &str, + initial_action_name: &str, + target_automaton: &Automaton, + target_action: &super::Action, + findings: &mut Vec, +) { + if target_action.from.is_empty() + || target_action + .from + .iter() + .any(|from| from == &target_automaton.automaton.initial) + { + return; + } + + findings.push(BundleLintFinding::error( + entity_name.to_string(), + "spawn_initial_action_not_from_initial_state", + format!( + "action '{}' spawns '{}' with initial_action '{}' not enabled from target initial state '{}'", + action.name, entity_type, initial_action_name, target_automaton.automaton.initial + ), + )); +} + +fn lint_spawn_param_mapping( + entity_name: &str, + parent_snake: &str, + action: &super::Action, + entity_type: &str, + initial_action_name: &str, + target_action: &super::Action, + findings: &mut Vec, +) { + if target_action.params.is_empty() { + return; + } + + let available_params = available_spawn_params(action, parent_snake); + let missing_params: Vec = target_action + .params + .iter() + .filter(|param| !available_params.contains(*param)) + .cloned() + .collect(); + + if missing_params.is_empty() { + return; + } + + let available: Vec = available_params.into_iter().collect(); + findings.push(BundleLintFinding::error( + entity_name.to_string(), + "spawn_initial_action_params_unmapped", + format!( + "action '{}' spawns '{}' -> '{}'; missing params {:?}, available params {:?}", + action.name, entity_type, initial_action_name, missing_params, available + ), + )); +} + +fn available_spawn_params(action: &super::Action, parent_snake: &str) -> BTreeSet { + let mut available_params: BTreeSet = action.params.iter().cloned().collect(); + available_params.insert("parent_id".to_string()); + available_params.insert("parent_type".to_string()); + available_params.insert(format!("{parent_snake}_id")); + available_params +} + +fn sort_bundle_findings(findings: &mut [BundleLintFinding]) { findings.sort_by(|a, b| { let key_a = ( &a.entity, @@ -261,8 +326,6 @@ pub fn lint_automata_bundle(automata: &BTreeMap) -> Vec bool { @@ -371,291 +434,5 @@ fn to_snake_case(value: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - use crate::automaton::parse_automaton; - use std::collections::BTreeMap; - - #[test] - fn lint_rejects_unknown_state_var_type() { - let src = r#" -[automaton] -name = "Task" -states = ["Draft", "Done"] -initial = "Draft" - -[[state]] -name = "mystery" -type = "mystery_type" -initial = "0" - -[[action]] -name = "Complete" -from = ["Draft"] -to = "Done" -"#; - let automaton = parse_automaton(src).expect("parse"); - let findings = lint_automaton(&automaton); - assert!( - findings - .iter() - .any(|f| f.code == "unknown_state_var_type" && f.severity == LintSeverity::Error) - ); - } - - #[test] - fn lint_rejects_unknown_guard_and_effect_variables() { - let src = r#" -[automaton] -name = "Task" -states = ["Draft", "Done"] -initial = "Draft" - -[[state]] -name = "approved" -type = "bool" -initial = "false" - -[[action]] -name = "Complete" -from = ["Draft"] -to = "Done" -guard = "is_true phantom" -effect = "set ghost true" -"#; - let automaton = parse_automaton(src).expect("parse"); - let findings = lint_automaton(&automaton); - assert!( - findings - .iter() - .any(|f| f.code == "guard_unknown_var" && f.severity == LintSeverity::Error) - ); - assert!( - findings - .iter() - .any(|f| f.code == "effect_unknown_var" && f.severity == LintSeverity::Error) - ); - } - - #[test] - fn lint_warns_for_missing_to_on_internal_action() { - let src = r#" -[automaton] -name = "Task" -states = ["Draft", "Done"] -initial = "Draft" - -[[action]] -name = "Nop" -kind = "internal" -from = ["Draft"] -"#; - let automaton = parse_automaton(src).expect("parse"); - let findings = lint_automaton(&automaton); - assert!( - findings - .iter() - .any(|f| f.code == "action_missing_to" && f.severity == LintSeverity::Warning) - ); - } - - #[test] - fn lint_allows_missing_to_for_output_action() { - let src = r#" -[automaton] -name = "Task" -states = ["Draft", "Done"] -initial = "Draft" - -[[action]] -name = "EmitAudit" -kind = "output" -from = ["Draft"] -effect = "emit audit" -"#; - let automaton = parse_automaton(src).expect("parse"); - let findings = lint_automaton(&automaton); - assert!(!findings.iter().any(|f| f.code == "action_missing_to")); - } - - fn parse(src: &str) -> Automaton { - parse_automaton(src).expect("parse") - } - - #[test] - fn bundle_lint_rejects_missing_spawn_target() { - let parent = parse( - r#" -[automaton] -name = "Plan" -states = ["Draft"] -initial = "Draft" - -[[action]] -name = "AddTask" -from = ["Draft"] -effect = [{ type = "spawn", entity_type = "Task", entity_id_source = "{uuid}", initial_action = "Create" }] -"#, - ); - - let bundle = BTreeMap::from([("Plan".to_string(), parent)]); - let findings = lint_automata_bundle(&bundle); - assert!(findings.iter().any(|f| { - f.code == "spawn_target_missing" - && f.entity == "Plan" - && f.severity == LintSeverity::Error - })); - } - - #[test] - fn bundle_lint_rejects_missing_spawn_initial_action() { - let parent = parse( - r#" -[automaton] -name = "Plan" -states = ["Draft"] -initial = "Draft" - -[[action]] -name = "AddTask" -from = ["Draft"] -effect = [{ type = "spawn", entity_type = "Task", entity_id_source = "{uuid}", initial_action = "Create" }] -"#, - ); - let child = parse( - r#" -[automaton] -name = "Task" -states = ["Open", "Done"] -initial = "Open" - -[[action]] -name = "Complete" -from = ["Open"] -to = "Done" -"#, - ); - - let bundle = BTreeMap::from([("Plan".to_string(), parent), ("Task".to_string(), child)]); - let findings = lint_automata_bundle(&bundle); - assert!( - findings - .iter() - .any(|f| f.code == "spawn_initial_action_missing" && f.entity == "Plan") - ); - } - - #[test] - fn bundle_lint_rejects_spawn_initial_action_not_enabled_from_initial() { - let parent = parse( - r#" -[automaton] -name = "Plan" -states = ["Draft"] -initial = "Draft" - -[[action]] -name = "AddTask" -from = ["Draft"] -effect = [{ type = "spawn", entity_type = "Task", entity_id_source = "{uuid}", initial_action = "Create" }] -"#, - ); - let child = parse( - r#" -[automaton] -name = "Task" -states = ["Open", "InProgress"] -initial = "Open" - -[[action]] -name = "Create" -from = ["InProgress"] -"#, - ); - - let bundle = BTreeMap::from([("Plan".to_string(), parent), ("Task".to_string(), child)]); - let findings = lint_automata_bundle(&bundle); - assert!( - findings - .iter() - .any(|f| f.code == "spawn_initial_action_not_from_initial_state") - ); - } - - #[test] - fn bundle_lint_rejects_unmapped_spawn_params() { - let parent = parse( - r#" -[automaton] -name = "Plan" -states = ["Draft"] -initial = "Draft" - -[[action]] -name = "AddTask" -from = ["Draft"] -params = ["title"] -effect = [{ type = "spawn", entity_type = "Task", entity_id_source = "{uuid}", initial_action = "Create" }] -"#, - ); - let child = parse( - r#" -[automaton] -name = "Task" -states = ["Open"] -initial = "Open" - -[[action]] -name = "Create" -from = ["Open"] -params = ["title", "description", "plan_id"] -"#, - ); - - let bundle = BTreeMap::from([("Plan".to_string(), parent), ("Task".to_string(), child)]); - let findings = lint_automata_bundle(&bundle); - assert!(findings.iter().any(|f| { - f.code == "spawn_initial_action_params_unmapped" - && f.entity == "Plan" - && f.message.contains("description") - })); - } - - #[test] - fn bundle_lint_accepts_valid_spawn_contract() { - let parent = parse( - r#" -[automaton] -name = "Plan" -states = ["Active"] -initial = "Active" - -[[action]] -name = "AddTask" -from = ["Active"] -params = ["title", "description"] -effect = [{ type = "spawn", entity_type = "Task", entity_id_source = "{uuid}", initial_action = "Create" }] -"#, - ); - let child = parse( - r#" -[automaton] -name = "Task" -states = ["Open"] -initial = "Open" - -[[action]] -name = "Create" -from = ["Open"] -params = ["title", "description", "plan_id"] -"#, - ); - - let bundle = BTreeMap::from([("Plan".to_string(), parent), ("Task".to_string(), child)]); - let findings = lint_automata_bundle(&bundle); - assert!( - findings.is_empty(), - "expected no bundle lint findings, got: {findings:?}" - ); - } -} +#[path = "lint_test.rs"] +mod tests; diff --git a/crates/temper-spec/src/automaton/lint_test.rs b/crates/temper-spec/src/automaton/lint_test.rs new file mode 100644 index 00000000..c4e545ef --- /dev/null +++ b/crates/temper-spec/src/automaton/lint_test.rs @@ -0,0 +1,292 @@ +use super::*; +use crate::automaton::parse_automaton; +use std::collections::BTreeMap; + +#[test] +fn lint_rejects_unknown_state_var_type() { + let src = r#" +[automaton] +name = "Task" +states = ["Draft", "Done"] +initial = "Draft" + +[[state]] +name = "mystery" +type = "mystery_type" +initial = "0" + +[[action]] +name = "Complete" +from = ["Draft"] +to = "Done" +"#; + let automaton = parse_automaton(src).expect("parse"); + let findings = lint_automaton(&automaton); + assert!( + findings + .iter() + .any(|finding| finding.code == "unknown_state_var_type" + && finding.severity == LintSeverity::Error) + ); +} + +#[test] +fn lint_rejects_unknown_guard_and_effect_variables() { + let src = r#" +[automaton] +name = "Task" +states = ["Draft", "Done"] +initial = "Draft" + +[[state]] +name = "approved" +type = "bool" +initial = "false" + +[[action]] +name = "Complete" +from = ["Draft"] +to = "Done" +guard = "is_true phantom" +effect = "set ghost true" +"#; + let automaton = parse_automaton(src).expect("parse"); + let findings = lint_automaton(&automaton); + assert!( + findings + .iter() + .any(|finding| finding.code == "guard_unknown_var" + && finding.severity == LintSeverity::Error) + ); + assert!( + findings + .iter() + .any(|finding| finding.code == "effect_unknown_var" + && finding.severity == LintSeverity::Error) + ); +} + +#[test] +fn lint_warns_for_missing_to_on_internal_action() { + let src = r#" +[automaton] +name = "Task" +states = ["Draft", "Done"] +initial = "Draft" + +[[action]] +name = "Nop" +kind = "internal" +from = ["Draft"] +"#; + let automaton = parse_automaton(src).expect("parse"); + let findings = lint_automaton(&automaton); + assert!( + findings + .iter() + .any(|finding| finding.code == "action_missing_to" + && finding.severity == LintSeverity::Warning) + ); +} + +#[test] +fn lint_allows_missing_to_for_output_action() { + let src = r#" +[automaton] +name = "Task" +states = ["Draft", "Done"] +initial = "Draft" + +[[action]] +name = "EmitAudit" +kind = "output" +from = ["Draft"] +effect = "emit audit" +"#; + let automaton = parse_automaton(src).expect("parse"); + let findings = lint_automaton(&automaton); + assert!( + !findings + .iter() + .any(|finding| finding.code == "action_missing_to") + ); +} + +fn parse(src: &str) -> Automaton { + parse_automaton(src).expect("parse") +} + +#[test] +fn bundle_lint_rejects_missing_spawn_target() { + let parent = parse( + r#" +[automaton] +name = "Plan" +states = ["Draft"] +initial = "Draft" + +[[action]] +name = "AddTask" +from = ["Draft"] +effect = [{ type = "spawn", entity_type = "Task", entity_id_source = "{uuid}", initial_action = "Create" }] +"#, + ); + + let bundle = BTreeMap::from([("Plan".to_string(), parent)]); + let findings = lint_automata_bundle(&bundle); + assert!(findings.iter().any(|finding| { + finding.code == "spawn_target_missing" + && finding.entity == "Plan" + && finding.severity == LintSeverity::Error + })); +} + +#[test] +fn bundle_lint_rejects_missing_spawn_initial_action() { + let parent = parse( + r#" +[automaton] +name = "Plan" +states = ["Draft"] +initial = "Draft" + +[[action]] +name = "AddTask" +from = ["Draft"] +effect = [{ type = "spawn", entity_type = "Task", entity_id_source = "{uuid}", initial_action = "Create" }] +"#, + ); + let child = parse( + r#" +[automaton] +name = "Task" +states = ["Open", "Done"] +initial = "Open" + +[[action]] +name = "Complete" +from = ["Open"] +to = "Done" +"#, + ); + + let bundle = BTreeMap::from([("Plan".to_string(), parent), ("Task".to_string(), child)]); + let findings = lint_automata_bundle(&bundle); + assert!(findings.iter().any(|finding| { + finding.code == "spawn_initial_action_missing" && finding.entity == "Plan" + })); +} + +#[test] +fn bundle_lint_rejects_spawn_initial_action_not_enabled_from_initial() { + let parent = parse( + r#" +[automaton] +name = "Plan" +states = ["Draft"] +initial = "Draft" + +[[action]] +name = "AddTask" +from = ["Draft"] +effect = [{ type = "spawn", entity_type = "Task", entity_id_source = "{uuid}", initial_action = "Create" }] +"#, + ); + let child = parse( + r#" +[automaton] +name = "Task" +states = ["Open", "InProgress"] +initial = "Open" + +[[action]] +name = "Create" +from = ["InProgress"] +"#, + ); + + let bundle = BTreeMap::from([("Plan".to_string(), parent), ("Task".to_string(), child)]); + let findings = lint_automata_bundle(&bundle); + assert!( + findings + .iter() + .any(|finding| { finding.code == "spawn_initial_action_not_from_initial_state" }) + ); +} + +#[test] +fn bundle_lint_rejects_unmapped_spawn_params() { + let parent = parse( + r#" +[automaton] +name = "Plan" +states = ["Draft"] +initial = "Draft" + +[[action]] +name = "AddTask" +from = ["Draft"] +params = ["title"] +effect = [{ type = "spawn", entity_type = "Task", entity_id_source = "{uuid}", initial_action = "Create" }] +"#, + ); + let child = parse( + r#" +[automaton] +name = "Task" +states = ["Open"] +initial = "Open" + +[[action]] +name = "Create" +from = ["Open"] +params = ["title", "description", "plan_id"] +"#, + ); + + let bundle = BTreeMap::from([("Plan".to_string(), parent), ("Task".to_string(), child)]); + let findings = lint_automata_bundle(&bundle); + assert!(findings.iter().any(|finding| { + finding.code == "spawn_initial_action_params_unmapped" + && finding.entity == "Plan" + && finding.message.contains("description") + })); +} + +#[test] +fn bundle_lint_accepts_valid_spawn_contract() { + let parent = parse( + r#" +[automaton] +name = "Plan" +states = ["Active"] +initial = "Active" + +[[action]] +name = "AddTask" +from = ["Active"] +params = ["title", "description"] +effect = [{ type = "spawn", entity_type = "Task", entity_id_source = "{uuid}", initial_action = "Create" }] +"#, + ); + let child = parse( + r#" +[automaton] +name = "Task" +states = ["Open"] +initial = "Open" + +[[action]] +name = "Create" +from = ["Open"] +params = ["title", "description", "plan_id"] +"#, + ); + + let bundle = BTreeMap::from([("Plan".to_string(), parent), ("Task".to_string(), child)]); + let findings = lint_automata_bundle(&bundle); + assert!( + findings.is_empty(), + "expected no bundle lint findings, got: {findings:?}" + ); +} diff --git a/crates/temper-spec/src/automaton/parser.rs b/crates/temper-spec/src/automaton/parser.rs index 90a8a857..fa156e75 100644 --- a/crates/temper-spec/src/automaton/parser.rs +++ b/crates/temper-spec/src/automaton/parser.rs @@ -230,592 +230,5 @@ fn validate(automaton: &Automaton) -> Result<(), AutomatonParseError> { } #[cfg(test)] -mod tests { - use super::*; - - const ORDER_IOA: &str = include_str!("../../../../test-fixtures/specs/order.ioa.toml"); - - #[test] - fn test_parse_order_automaton() { - let automaton = parse_automaton(ORDER_IOA).expect("should parse"); - assert_eq!(automaton.automaton.name, "Order"); - assert_eq!(automaton.automaton.initial, "Draft"); - assert_eq!(automaton.automaton.states.len(), 10); - assert!(automaton.automaton.states.contains(&"Draft".to_string())); - assert!(automaton.automaton.states.contains(&"Shipped".to_string())); - } - - #[test] - fn test_actions_parsed() { - let automaton = parse_automaton(ORDER_IOA).unwrap(); - let names: Vec<&str> = automaton.actions.iter().map(|a| a.name.as_str()).collect(); - assert!(names.contains(&"AddItem"), "got: {names:?}"); - assert!(names.contains(&"SubmitOrder")); - assert!(names.contains(&"CancelOrder")); - assert!(names.contains(&"ConfirmOrder")); - } - - #[test] - fn test_submit_order_has_guard() { - let automaton = parse_automaton(ORDER_IOA).unwrap(); - let submit = automaton - .actions - .iter() - .find(|a| a.name == "SubmitOrder") - .unwrap(); - assert_eq!(submit.from, vec!["Draft"]); - assert_eq!(submit.to, Some("Submitted".to_string())); - assert!(!submit.guard.is_empty(), "SubmitOrder should have a guard"); - } - - #[test] - fn test_cancel_from_multiple_states() { - let automaton = parse_automaton(ORDER_IOA).unwrap(); - let cancel = automaton - .actions - .iter() - .find(|a| a.name == "CancelOrder") - .unwrap(); - assert_eq!(cancel.from.len(), 3); - assert!(cancel.from.contains(&"Draft".to_string())); - assert!(cancel.from.contains(&"Submitted".to_string())); - assert!(cancel.from.contains(&"Confirmed".to_string())); - } - - #[test] - fn test_invariants_parsed() { - let automaton = parse_automaton(ORDER_IOA).unwrap(); - assert!(!automaton.invariants.is_empty()); - let names: Vec<&str> = automaton - .invariants - .iter() - .map(|i| i.name.as_str()) - .collect(); - assert!(names.contains(&"SubmitRequiresItems"), "got: {names:?}"); - } - - #[test] - fn test_convert_to_state_machine() { - let automaton = parse_automaton(ORDER_IOA).unwrap(); - let sm = to_state_machine(&automaton); - assert_eq!(sm.module_name, "Order"); - assert_eq!(sm.states.len(), 10); - assert!(!sm.transitions.is_empty()); - assert!(!sm.invariants.is_empty()); - - let submit = sm - .transitions - .iter() - .find(|t| t.name == "SubmitOrder") - .unwrap(); - assert_eq!(submit.from_states, vec!["Draft"]); - assert_eq!(submit.to_state, Some("Submitted".to_string())); - } - - #[test] - fn test_invalid_initial_state_rejected() { - let toml = r#" -[automaton] -name = "Bad" -states = ["A", "B"] -initial = "C" -"#; - let result = parse_automaton(toml); - assert!(result.is_err()); - } - - #[test] - fn test_invalid_from_state_rejected() { - let toml = r#" -[automaton] -name = "Bad" -states = ["A", "B"] -initial = "A" - -[[action]] -name = "Go" -from = ["Z"] -to = "B" -"#; - let result = parse_automaton(toml); - assert!(result.is_err()); - } - - #[test] - fn test_integration_section_parsed() { - let toml = r#" -[automaton] -name = "Order" -states = ["Draft", "Submitted"] -initial = "Draft" - -[[action]] -name = "SubmitOrder" -from = ["Draft"] -to = "Submitted" - -[[integration]] -name = "notify_fulfillment" -trigger = "SubmitOrder" -type = "webhook" - -[[integration]] -name = "charge_payment" -trigger = "ConfirmOrder" -type = "webhook" -"#; - let automaton = parse_automaton(toml).expect("should parse"); - assert_eq!(automaton.integrations.len(), 2); - assert_eq!(automaton.integrations[0].name, "notify_fulfillment"); - assert_eq!(automaton.integrations[0].trigger, "SubmitOrder"); - assert_eq!(automaton.integrations[0].integration_type, "webhook"); - assert_eq!(automaton.integrations[1].name, "charge_payment"); - } - - #[test] - fn test_integration_default_type() { - let toml = r#" -[automaton] -name = "Order" -states = ["Draft", "Submitted"] -initial = "Draft" - -[[integration]] -name = "notify" -trigger = "SubmitOrder" -"#; - let automaton = parse_automaton(toml).expect("should parse"); - assert_eq!(automaton.integrations.len(), 1); - assert_eq!(automaton.integrations[0].integration_type, "webhook"); - } - - #[test] - fn test_no_integrations_defaults_empty() { - let automaton = parse_automaton(ORDER_IOA).expect("should parse"); - assert!(automaton.integrations.is_empty() || !automaton.integrations.is_empty()); - } - - #[test] - fn test_trigger_effect_parsed() { - let toml = r#" -[automaton] -name = "Order" -states = ["Submitted", "ChargePending", "Confirmed", "PaymentFailed"] -initial = "Submitted" - -[[action]] -name = "ChargePayment" -from = ["Submitted"] -to = "ChargePending" -effect = "trigger charge_payment" - -[[action]] -name = "ChargeSucceeded" -kind = "input" -from = ["ChargePending"] -to = "Confirmed" - -[[action]] -name = "ChargeFailed" -kind = "input" -from = ["ChargePending"] -to = "PaymentFailed" -"#; - let automaton = parse_automaton(toml).expect("should parse"); - let charge = automaton - .actions - .iter() - .find(|a| a.name == "ChargePayment") - .unwrap(); - assert_eq!(charge.effect.len(), 1); - match &charge.effect[0] { - Effect::Trigger { name } => assert_eq!(name, "charge_payment"), - other => panic!("expected Trigger effect, got: {other:?}"), - } - } - - #[test] - fn test_wasm_integration_parsed() { - let toml = r#" -[automaton] -name = "Order" -states = ["Submitted", "ChargePending", "Confirmed", "PaymentFailed"] -initial = "Submitted" - -[[action]] -name = "ChargePayment" -from = ["Submitted"] -to = "ChargePending" -effect = "trigger charge_payment" - -[[action]] -name = "ChargeSucceeded" -kind = "input" -from = ["ChargePending"] -to = "Confirmed" - -[[action]] -name = "ChargeFailed" -kind = "input" -from = ["ChargePending"] -to = "PaymentFailed" - -[[integration]] -name = "charge_payment" -trigger = "charge_payment" -type = "wasm" -module = "stripe_charge" -on_success = "ChargeSucceeded" -on_failure = "ChargeFailed" -"#; - let automaton = parse_automaton(toml).expect("should parse"); - assert_eq!(automaton.integrations.len(), 1); - let ig = &automaton.integrations[0]; - assert_eq!(ig.name, "charge_payment"); - assert_eq!(ig.integration_type, "wasm"); - assert_eq!(ig.module.as_deref(), Some("stripe_charge")); - assert_eq!(ig.on_success.as_deref(), Some("ChargeSucceeded")); - assert_eq!(ig.on_failure.as_deref(), Some("ChargeFailed")); - } - - #[test] - fn test_wasm_integration_missing_module_rejected() { - let toml = r#" -[automaton] -name = "Order" -states = ["Submitted", "ChargePending"] -initial = "Submitted" - -[[action]] -name = "ChargePayment" -from = ["Submitted"] -to = "ChargePending" - -[[integration]] -name = "charge_payment" -trigger = "charge_payment" -type = "wasm" -"#; - let result = parse_automaton(toml); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("missing 'module'"), "got: {err}"); - } - - #[test] - fn test_wasm_integration_unknown_callback_rejected() { - let toml = r#" -[automaton] -name = "Order" -states = ["Submitted", "ChargePending", "Confirmed"] -initial = "Submitted" - -[[action]] -name = "ChargePayment" -from = ["Submitted"] -to = "ChargePending" - -[[integration]] -name = "charge_payment" -trigger = "charge_payment" -type = "wasm" -module = "stripe_charge" -on_success = "NonExistentAction" -"#; - let result = parse_automaton(toml); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("NonExistentAction"), - "should mention missing action, got: {err}" - ); - } - - #[test] - fn test_valid_state_var_types_accepted() { - let spec = r#" -[automaton] -name = "Task" -states = ["Open", "Done"] -initial = "Open" - -[[state]] -name = "is_done" -type = "bool" -initial = "false" - -[[state]] -name = "attempt_count" -type = "counter" -initial = "0" - -[[action]] -name = "Complete" -kind = "input" -from = ["Open"] -to = "Done" -effect = "set is_done true" -"#; - let result = parse_automaton(spec); - assert!( - result.is_ok(), - "bool and counter types should be accepted: {:?}", - result.err() - ); - } - - #[test] - fn test_extended_guard_syntax_parsed() { - let spec = r#" -[automaton] -name = "Ticket" -states = ["Open", "Queued", "Closed"] -initial = "Open" - -[[action]] -name = "Queue" -from = ["Open"] -to = "Queued" -guard = "max retries 3" - -[[action]] -name = "Escalate" -from = ["Queued"] -to = "Queued" -guard = "list_contains labels urgent" - -[[action]] -name = "Close" -from = ["Queued"] -to = "Closed" -guard = "list_length_min labels 1" -"#; - - let automaton = parse_automaton(spec).expect("extended guard forms should parse"); - let queue = automaton - .actions - .iter() - .find(|a| a.name == "Queue") - .unwrap(); - assert!(matches!( - queue.guard.as_slice(), - [Guard::MaxCount { var, max }] if var == "retries" && *max == 3 - )); - - let escalate = automaton - .actions - .iter() - .find(|a| a.name == "Escalate") - .unwrap(); - assert!(matches!( - escalate.guard.as_slice(), - [Guard::ListContains { var, value }] if var == "labels" && value == "urgent" - )); - - let close = automaton - .actions - .iter() - .find(|a| a.name == "Close") - .unwrap(); - assert!(matches!( - close.guard.as_slice(), - [Guard::ListLengthMin { var, min }] if var == "labels" && *min == 1 - )); - } - - #[test] - fn test_integration_config_captures_unknown_keys() { - let toml = r#" -[automaton] -name = "Weather" -states = ["Idle", "Fetching", "Ready", "Failed"] -initial = "Idle" - -[[action]] -name = "FetchWeather" -from = ["Idle"] -to = "Fetching" -effect = "trigger fetch_weather" - -[[action]] -name = "FetchSucceeded" -kind = "input" -from = ["Fetching"] -to = "Ready" - -[[action]] -name = "FetchFailed" -kind = "input" -from = ["Fetching"] -to = "Failed" - -[[integration]] -name = "fetch_weather" -trigger = "fetch_weather" -type = "wasm" -module = "http_fetch" -on_success = "FetchSucceeded" -on_failure = "FetchFailed" -url = "https://api.open-meteo.com/v1/forecast" -method = "GET" -"#; - let automaton = parse_automaton(toml).expect("should parse"); - assert_eq!(automaton.integrations.len(), 1); - let ig = &automaton.integrations[0]; - assert_eq!(ig.name, "fetch_weather"); - assert_eq!(ig.integration_type, "wasm"); - assert_eq!(ig.module.as_deref(), Some("http_fetch")); - assert_eq!( - ig.config.get("url").map(String::as_str), - Some("https://api.open-meteo.com/v1/forecast") - ); - assert_eq!(ig.config.get("method").map(String::as_str), Some("GET")); - // Known keys should NOT be in config - assert!(!ig.config.contains_key("name")); - assert!(!ig.config.contains_key("trigger")); - assert!(!ig.config.contains_key("type")); - assert!(!ig.config.contains_key("module")); - } - - #[test] - fn test_invalid_guard_number_rejected() { - let spec = r#" -[automaton] -name = "Order" -states = ["Draft", "Submitted"] -initial = "Draft" - -[[action]] -name = "SubmitOrder" -from = ["Draft"] -to = "Submitted" -guard = "items > nope" -"#; - - let err = parse_automaton(spec).expect_err("invalid numeric guard should fail"); - assert!(err.to_string().contains("right side must be an integer")); - } - - #[test] - fn test_parse_schedule_effect() { - let spec = r#" -[automaton] -name = "OAuthToken" -states = ["Active", "Refreshing", "Expired"] -initial = "Active" - -[[action]] -name = "Activate" -from = ["Refreshing"] -to = "Active" -effect = [{ type = "schedule", action = "Refresh", delay_seconds = 2700 }] -"#; - - let automaton = parse_automaton(spec).expect("should parse schedule effect"); - let activate = automaton - .actions - .iter() - .find(|a| a.name == "Activate") - .unwrap(); - assert_eq!(activate.effect.len(), 1); - match &activate.effect[0] { - Effect::Schedule { - action, - delay_seconds, - } => { - assert_eq!(action, "Refresh"); - assert_eq!(*delay_seconds, 2700); - } - other => panic!("expected Schedule, got: {:?}", other), - } - } - - #[test] - fn test_unknown_inline_effect_type_rejected() { - let spec = r#" -[automaton] -name = "Broken" -states = ["Draft", "Done"] -initial = "Draft" - -[[action]] -name = "Complete" -from = ["Draft"] -to = "Done" -effect = [{ type = "mystery_effect", value = "x" }] -"#; - let err = parse_automaton(spec).expect_err("unknown inline effect type should fail"); - assert!( - err.to_string() - .contains("unsupported effect type 'mystery_effect'") - ); - } - - #[test] - fn test_legacy_inline_effect_aliases_supported() { - let spec = r#" -[automaton] -name = "Plan" -states = ["Active"] -initial = "Active" - -[[action]] -name = "AddTask" -from = ["Active"] -effect = [ - { type = "spawn_entity", entity_type = "Task", entity_id_source = "{uuid}", initial_action = "Create" }, - { type = "emit_event", event = "TaskAdded" } -] -"#; - let automaton = parse_automaton(spec).expect("legacy aliases should parse"); - let add_task = automaton - .actions - .iter() - .find(|a| a.name == "AddTask") - .expect("AddTask action should exist"); - assert!(matches!( - add_task.effect.first(), - Some(Effect::Spawn { .. }) - )); - assert!(matches!(add_task.effect.get(1), Some(Effect::Emit { .. }))); - } - - #[test] - fn test_agent_trigger_parsed() { - let spec = r#" -[automaton] -name = "Project" -states = ["Draft", "Ready"] -initial = "Draft" - -[[action]] -name = "MarkReady" -from = ["Draft"] -to = "Ready" - -[[agent_trigger]] -name = "test_on_ready" -on_action = "MarkReady" -to_state = "Ready" -agent_role = "tester" -agent_goal = "Run integration tests" -agent_type_id = "tester-type-1" -"#; - let automaton = parse_automaton(spec).expect("agent_trigger should parse"); - assert_eq!(automaton.agent_triggers.len(), 1); - let trigger = &automaton.agent_triggers[0]; - assert_eq!(trigger.name, "test_on_ready"); - assert_eq!(trigger.on_action, "MarkReady"); - assert_eq!(trigger.to_state, Some("Ready".to_string())); - assert_eq!(trigger.agent_role, "tester"); - assert_eq!(trigger.agent_goal, "Run integration tests"); - assert_eq!(trigger.agent_type_id, Some("tester-type-1".to_string())); - assert!(trigger.agent_model.is_none()); - } - - #[test] - fn test_agent_trigger_defaults_empty() { - let automaton = parse_automaton(ORDER_IOA).expect("should parse"); - assert!(automaton.agent_triggers.is_empty()); - } -} +#[path = "parser_test.rs"] +mod tests; diff --git a/crates/temper-spec/src/automaton/parser_test.rs b/crates/temper-spec/src/automaton/parser_test.rs new file mode 100644 index 00000000..31ab022c --- /dev/null +++ b/crates/temper-spec/src/automaton/parser_test.rs @@ -0,0 +1,10 @@ +pub(super) const ORDER_IOA: &str = include_str!("../../../../test-fixtures/specs/order.ioa.toml"); + +#[path = "parser_test_core.rs"] +mod core; +#[path = "parser_test_features.rs"] +mod features; +#[path = "parser_test_integrations.rs"] +mod integrations; +#[path = "parser_test_triggers.rs"] +mod triggers; diff --git a/crates/temper-spec/src/automaton/parser_test_core.rs b/crates/temper-spec/src/automaton/parser_test_core.rs new file mode 100644 index 00000000..f78f3967 --- /dev/null +++ b/crates/temper-spec/src/automaton/parser_test_core.rs @@ -0,0 +1,112 @@ +use super::super::*; +use super::ORDER_IOA; + +#[test] +fn test_parse_order_automaton() { + let automaton = parse_automaton(ORDER_IOA).expect("should parse"); + assert_eq!(automaton.automaton.name, "Order"); + assert_eq!(automaton.automaton.initial, "Draft"); + assert_eq!(automaton.automaton.states.len(), 10); + assert!(automaton.automaton.states.contains(&"Draft".to_string())); + assert!(automaton.automaton.states.contains(&"Shipped".to_string())); +} + +#[test] +fn test_actions_parsed() { + let automaton = parse_automaton(ORDER_IOA).unwrap(); + let names: Vec<&str> = automaton + .actions + .iter() + .map(|action| action.name.as_str()) + .collect(); + assert!(names.contains(&"AddItem"), "got: {names:?}"); + assert!(names.contains(&"SubmitOrder")); + assert!(names.contains(&"CancelOrder")); + assert!(names.contains(&"ConfirmOrder")); +} + +#[test] +fn test_submit_order_has_guard() { + let automaton = parse_automaton(ORDER_IOA).unwrap(); + let submit = automaton + .actions + .iter() + .find(|action| action.name == "SubmitOrder") + .unwrap(); + assert_eq!(submit.from, vec!["Draft"]); + assert_eq!(submit.to, Some("Submitted".to_string())); + assert!(!submit.guard.is_empty(), "SubmitOrder should have a guard"); +} + +#[test] +fn test_cancel_from_multiple_states() { + let automaton = parse_automaton(ORDER_IOA).unwrap(); + let cancel = automaton + .actions + .iter() + .find(|action| action.name == "CancelOrder") + .unwrap(); + assert_eq!(cancel.from.len(), 3); + assert!(cancel.from.contains(&"Draft".to_string())); + assert!(cancel.from.contains(&"Submitted".to_string())); + assert!(cancel.from.contains(&"Confirmed".to_string())); +} + +#[test] +fn test_invariants_parsed() { + let automaton = parse_automaton(ORDER_IOA).unwrap(); + assert!(!automaton.invariants.is_empty()); + let names: Vec<&str> = automaton + .invariants + .iter() + .map(|invariant| invariant.name.as_str()) + .collect(); + assert!(names.contains(&"SubmitRequiresItems"), "got: {names:?}"); +} + +#[test] +fn test_convert_to_state_machine() { + let automaton = parse_automaton(ORDER_IOA).unwrap(); + let state_machine = to_state_machine(&automaton); + assert_eq!(state_machine.module_name, "Order"); + assert_eq!(state_machine.states.len(), 10); + assert!(!state_machine.transitions.is_empty()); + assert!(!state_machine.invariants.is_empty()); + + let submit = state_machine + .transitions + .iter() + .find(|transition| transition.name == "SubmitOrder") + .unwrap(); + assert_eq!(submit.from_states, vec!["Draft"]); + assert_eq!(submit.to_state, Some("Submitted".to_string())); +} + +#[test] +fn test_invalid_initial_state_rejected() { + let toml = r#" +[automaton] +name = "Bad" +states = ["A", "B"] +initial = "C" +"#; + let result = parse_automaton(toml); + assert!(result.is_err()); +} + +#[test] +fn test_invalid_from_state_rejected() { + let toml = r#" +[automaton] +name = "Bad" +states = ["A", "B"] +initial = "A" + +[[action]] +name = "Go" +from = ["Z"] +to = "B" +"#; + let result = parse_automaton(toml); + assert!(result.is_err()); +} diff --git a/crates/temper-spec/src/automaton/parser_test_features.rs b/crates/temper-spec/src/automaton/parser_test_features.rs new file mode 100644 index 00000000..a1115d22 --- /dev/null +++ b/crates/temper-spec/src/automaton/parser_test_features.rs @@ -0,0 +1,196 @@ +use super::super::*; + +#[test] +fn test_valid_state_var_types_accepted() { + let spec = r#" +[automaton] +name = "Task" +states = ["Open", "Done"] +initial = "Open" + +[[state]] +name = "is_done" +type = "bool" +initial = "false" + +[[state]] +name = "attempt_count" +type = "counter" +initial = "0" + +[[action]] +name = "Complete" +kind = "input" +from = ["Open"] +to = "Done" +effect = "set is_done true" +"#; + let result = parse_automaton(spec); + assert!( + result.is_ok(), + "bool and counter types should be accepted: {:?}", + result.err() + ); +} + +#[test] +fn test_extended_guard_syntax_parsed() { + let spec = r#" +[automaton] +name = "Ticket" +states = ["Open", "Queued", "Closed"] +initial = "Open" + +[[action]] +name = "Queue" +from = ["Open"] +to = "Queued" +guard = "max retries 3" + +[[action]] +name = "Escalate" +from = ["Queued"] +to = "Queued" +guard = "list_contains labels urgent" + +[[action]] +name = "Close" +from = ["Queued"] +to = "Closed" +guard = "list_length_min labels 1" +"#; + + let automaton = parse_automaton(spec).expect("extended guard forms should parse"); + let queue = automaton + .actions + .iter() + .find(|action| action.name == "Queue") + .unwrap(); + assert!(matches!( + queue.guard.as_slice(), + [Guard::MaxCount { var, max }] if var == "retries" && *max == 3 + )); + + let escalate = automaton + .actions + .iter() + .find(|action| action.name == "Escalate") + .unwrap(); + assert!(matches!( + escalate.guard.as_slice(), + [Guard::ListContains { var, value }] if var == "labels" && value == "urgent" + )); + + let close = automaton + .actions + .iter() + .find(|action| action.name == "Close") + .unwrap(); + assert!(matches!( + close.guard.as_slice(), + [Guard::ListLengthMin { var, min }] if var == "labels" && *min == 1 + )); +} + +#[test] +fn test_invalid_guard_number_rejected() { + let spec = r#" +[automaton] +name = "Order" +states = ["Draft", "Submitted"] +initial = "Draft" + +[[action]] +name = "SubmitOrder" +from = ["Draft"] +to = "Submitted" +guard = "items > nope" +"#; + + let err = parse_automaton(spec).expect_err("invalid numeric guard should fail"); + assert!(err.to_string().contains("right side must be an integer")); +} + +#[test] +fn test_parse_schedule_effect() { + let spec = r#" +[automaton] +name = "OAuthToken" +states = ["Active", "Refreshing", "Expired"] +initial = "Active" + +[[action]] +name = "Activate" +from = ["Refreshing"] +to = "Active" +effect = [{ type = "schedule", action = "Refresh", delay_seconds = 2700 }] +"#; + + let automaton = parse_automaton(spec).expect("should parse schedule effect"); + let activate = automaton + .actions + .iter() + .find(|action| action.name == "Activate") + .unwrap(); + assert_eq!(activate.effect.len(), 1); + match &activate.effect[0] { + Effect::Schedule { + action, + delay_seconds, + } => { + assert_eq!(action, "Refresh"); + assert_eq!(*delay_seconds, 2700); + } + other => panic!("expected Schedule, got: {other:?}"), + } +} + +#[test] +fn test_unknown_inline_effect_type_rejected() { + let spec = r#" +[automaton] +name = "Broken" +states = ["Draft", "Done"] +initial = "Draft" + +[[action]] +name = "Complete" +from = ["Draft"] +to = "Done" +effect = [{ type = "mystery_effect", value = "x" }] +"#; + let err = parse_automaton(spec).expect_err("unknown inline effect type should fail"); + assert!( + err.to_string() + .contains("unsupported effect type 'mystery_effect'") + ); +} + +#[test] +fn test_legacy_inline_effect_aliases_supported() { + let spec = r#" +[automaton] +name = "Plan" +states = ["Active"] +initial = "Active" + +[[action]] +name = "AddTask" +from = ["Active"] +effect = [ + { type = "spawn_entity", entity_type = "Task", entity_id_source = "{uuid}", initial_action = "Create" }, + { type = "emit_event", event = "TaskAdded" } +] +"#; + let automaton = parse_automaton(spec).expect("legacy aliases should parse"); + let add_task = automaton + .actions + .iter() + .find(|action| action.name == "AddTask") + .expect("AddTask action should exist"); + assert!(matches!( + add_task.effect.first(), + Some(Effect::Spawn { .. }) + )); + assert!(matches!(add_task.effect.get(1), Some(Effect::Emit { .. }))); +} diff --git a/crates/temper-spec/src/automaton/parser_test_integrations.rs b/crates/temper-spec/src/automaton/parser_test_integrations.rs new file mode 100644 index 00000000..16c868bb --- /dev/null +++ b/crates/temper-spec/src/automaton/parser_test_integrations.rs @@ -0,0 +1,248 @@ +use super::super::*; +use super::ORDER_IOA; + +#[test] +fn test_integration_section_parsed() { + let toml = r#" +[automaton] +name = "Order" +states = ["Draft", "Submitted"] +initial = "Draft" + +[[action]] +name = "SubmitOrder" +from = ["Draft"] +to = "Submitted" + +[[integration]] +name = "notify_fulfillment" +trigger = "SubmitOrder" +type = "webhook" + +[[integration]] +name = "charge_payment" +trigger = "ConfirmOrder" +type = "webhook" +"#; + let automaton = parse_automaton(toml).expect("should parse"); + assert_eq!(automaton.integrations.len(), 2); + assert_eq!(automaton.integrations[0].name, "notify_fulfillment"); + assert_eq!(automaton.integrations[0].trigger, "SubmitOrder"); + assert_eq!(automaton.integrations[0].integration_type, "webhook"); + assert_eq!(automaton.integrations[1].name, "charge_payment"); +} + +#[test] +fn test_integration_default_type() { + let toml = r#" +[automaton] +name = "Order" +states = ["Draft", "Submitted"] +initial = "Draft" + +[[integration]] +name = "notify" +trigger = "SubmitOrder" +"#; + let automaton = parse_automaton(toml).expect("should parse"); + assert_eq!(automaton.integrations.len(), 1); + assert_eq!(automaton.integrations[0].integration_type, "webhook"); +} + +#[test] +fn test_no_integrations_defaults_empty() { + let automaton = parse_automaton(ORDER_IOA).expect("should parse"); + assert!(automaton.integrations.is_empty()); +} + +#[test] +fn test_trigger_effect_parsed() { + let toml = r#" +[automaton] +name = "Order" +states = ["Submitted", "ChargePending", "Confirmed", "PaymentFailed"] +initial = "Submitted" + +[[action]] +name = "ChargePayment" +from = ["Submitted"] +to = "ChargePending" +effect = "trigger charge_payment" + +[[action]] +name = "ChargeSucceeded" +kind = "input" +from = ["ChargePending"] +to = "Confirmed" + +[[action]] +name = "ChargeFailed" +kind = "input" +from = ["ChargePending"] +to = "PaymentFailed" +"#; + let automaton = parse_automaton(toml).expect("should parse"); + let charge = automaton + .actions + .iter() + .find(|action| action.name == "ChargePayment") + .unwrap(); + assert_eq!(charge.effect.len(), 1); + match &charge.effect[0] { + Effect::Trigger { name } => assert_eq!(name, "charge_payment"), + other => panic!("expected Trigger effect, got: {other:?}"), + } +} + +#[test] +fn test_wasm_integration_parsed() { + let toml = r#" +[automaton] +name = "Order" +states = ["Submitted", "ChargePending", "Confirmed", "PaymentFailed"] +initial = "Submitted" + +[[action]] +name = "ChargePayment" +from = ["Submitted"] +to = "ChargePending" +effect = "trigger charge_payment" + +[[action]] +name = "ChargeSucceeded" +kind = "input" +from = ["ChargePending"] +to = "Confirmed" + +[[action]] +name = "ChargeFailed" +kind = "input" +from = ["ChargePending"] +to = "PaymentFailed" + +[[integration]] +name = "charge_payment" +trigger = "charge_payment" +type = "wasm" +module = "stripe_charge" +on_success = "ChargeSucceeded" +on_failure = "ChargeFailed" +"#; + let automaton = parse_automaton(toml).expect("should parse"); + assert_eq!(automaton.integrations.len(), 1); + let integration = &automaton.integrations[0]; + assert_eq!(integration.name, "charge_payment"); + assert_eq!(integration.integration_type, "wasm"); + assert_eq!(integration.module.as_deref(), Some("stripe_charge")); + assert_eq!(integration.on_success.as_deref(), Some("ChargeSucceeded")); + assert_eq!(integration.on_failure.as_deref(), Some("ChargeFailed")); +} + +#[test] +fn test_wasm_integration_missing_module_rejected() { + let toml = r#" +[automaton] +name = "Order" +states = ["Submitted", "ChargePending"] +initial = "Submitted" + +[[action]] +name = "ChargePayment" +from = ["Submitted"] +to = "ChargePending" + +[[integration]] +name = "charge_payment" +trigger = "charge_payment" +type = "wasm" +"#; + let result = parse_automaton(toml); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("missing 'module'"), "got: {err}"); +} + +#[test] +fn test_wasm_integration_unknown_callback_rejected() { + let toml = r#" +[automaton] +name = "Order" +states = ["Submitted", "ChargePending", "Confirmed"] +initial = "Submitted" + +[[action]] +name = "ChargePayment" +from = ["Submitted"] +to = "ChargePending" + +[[integration]] +name = "charge_payment" +trigger = "charge_payment" +type = "wasm" +module = "stripe_charge" +on_success = "NonExistentAction" +"#; + let result = parse_automaton(toml); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("NonExistentAction"), + "should mention missing action, got: {err}" + ); +} + +#[test] +fn test_integration_config_captures_unknown_keys() { + let toml = r#" +[automaton] +name = "Weather" +states = ["Idle", "Fetching", "Ready", "Failed"] +initial = "Idle" + +[[action]] +name = "FetchWeather" +from = ["Idle"] +to = "Fetching" +effect = "trigger fetch_weather" + +[[action]] +name = "FetchSucceeded" +kind = "input" +from = ["Fetching"] +to = "Ready" + +[[action]] +name = "FetchFailed" +kind = "input" +from = ["Fetching"] +to = "Failed" + +[[integration]] +name = "fetch_weather" +trigger = "fetch_weather" +type = "wasm" +module = "http_fetch" +on_success = "FetchSucceeded" +on_failure = "FetchFailed" +url = "https://api.open-meteo.com/v1/forecast" +method = "GET" +"#; + let automaton = parse_automaton(toml).expect("should parse"); + assert_eq!(automaton.integrations.len(), 1); + let integration = &automaton.integrations[0]; + assert_eq!(integration.name, "fetch_weather"); + assert_eq!(integration.integration_type, "wasm"); + assert_eq!(integration.module.as_deref(), Some("http_fetch")); + assert_eq!( + integration.config.get("url").map(String::as_str), + Some("https://api.open-meteo.com/v1/forecast") + ); + assert_eq!( + integration.config.get("method").map(String::as_str), + Some("GET") + ); + assert!(!integration.config.contains_key("name")); + assert!(!integration.config.contains_key("trigger")); + assert!(!integration.config.contains_key("type")); + assert!(!integration.config.contains_key("module")); +} diff --git a/crates/temper-spec/src/automaton/parser_test_triggers.rs b/crates/temper-spec/src/automaton/parser_test_triggers.rs new file mode 100644 index 00000000..d2ae617c --- /dev/null +++ b/crates/temper-spec/src/automaton/parser_test_triggers.rs @@ -0,0 +1,41 @@ +use super::super::*; +use super::ORDER_IOA; + +#[test] +fn test_agent_trigger_parsed() { + let spec = r#" +[automaton] +name = "Project" +states = ["Draft", "Ready"] +initial = "Draft" + +[[action]] +name = "MarkReady" +from = ["Draft"] +to = "Ready" + +[[agent_trigger]] +name = "test_on_ready" +on_action = "MarkReady" +to_state = "Ready" +agent_role = "tester" +agent_goal = "Run integration tests" +agent_type_id = "tester-type-1" +"#; + let automaton = parse_automaton(spec).expect("agent_trigger should parse"); + assert_eq!(automaton.agent_triggers.len(), 1); + let trigger = &automaton.agent_triggers[0]; + assert_eq!(trigger.name, "test_on_ready"); + assert_eq!(trigger.on_action, "MarkReady"); + assert_eq!(trigger.to_state, Some("Ready".to_string())); + assert_eq!(trigger.agent_role, "tester"); + assert_eq!(trigger.agent_goal, "Run integration tests"); + assert_eq!(trigger.agent_type_id, Some("tester-type-1".to_string())); + assert!(trigger.agent_model.is_none()); +} + +#[test] +fn test_agent_trigger_defaults_empty() { + let automaton = parse_automaton(ORDER_IOA).expect("should parse"); + assert!(automaton.agent_triggers.is_empty()); +} diff --git a/crates/temper-spec/src/automaton/toml_parser.rs b/crates/temper-spec/src/automaton/toml_parser.rs deleted file mode 100644 index 5209aefd..00000000 --- a/crates/temper-spec/src/automaton/toml_parser.rs +++ /dev/null @@ -1,925 +0,0 @@ -//! Minimal TOML parser for I/O Automaton specifications. -//! -//! Handles the subset of TOML used by IOA specs since we use a hand-rolled -//! parser rather than the full `toml` crate for the core parsing. Webhook -//! sections are delegated to `toml::from_str` in a second pass. - -use super::parser::AutomatonParseError; -use super::types::*; - -/// Parse TOML into an Automaton struct. -/// -/// This is a minimal parser that handles the subset of TOML we use: -/// - `[automaton]` table with name, states, initial -/// - `[[action]]` array of tables -/// - `[[invariant]]` array of tables -/// - Simple key = "value" and key = ["array"] syntax -pub(super) fn parse_toml_to_automaton(input: &str) -> Result { - let mut meta_name = String::new(); - let mut meta_states: Vec = Vec::new(); - let mut meta_initial = String::new(); - let mut state_vars: Vec = Vec::new(); - let mut actions: Vec = Vec::new(); - let mut invariants: Vec = Vec::new(); - let mut liveness_props: Vec = Vec::new(); - let mut integrations: Vec = Vec::new(); - - let mut current_section = ""; - let mut current_action: Option = None; - let mut current_invariant: Option = None; - let mut current_state_var: Option = None; - let mut current_liveness: Option = None; - let mut current_integration: Option = None; - - // Pre-process: join multiline array values (bracket continuation). - // Lines like `effect = [\n { ... },\n]` become a single logical line. - let logical_lines = join_multiline_arrays(input); - - for line in &logical_lines { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - - // Section headers - if trimmed == "[automaton]" { - flush_all( - &mut current_action, - &mut actions, - &mut current_invariant, - &mut invariants, - &mut current_state_var, - &mut state_vars, - &mut current_liveness, - &mut liveness_props, - ); - current_section = "automaton"; - continue; - } - if trimmed == "[[state]]" { - flush_all( - &mut current_action, - &mut actions, - &mut current_invariant, - &mut invariants, - &mut current_state_var, - &mut state_vars, - &mut current_liveness, - &mut liveness_props, - ); - current_state_var = Some(StateVar { - name: String::new(), - var_type: "string".into(), - initial: String::new(), - }); - current_section = "state"; - continue; - } - if trimmed == "[[action]]" { - flush_all( - &mut current_action, - &mut actions, - &mut current_invariant, - &mut invariants, - &mut current_state_var, - &mut state_vars, - &mut current_liveness, - &mut liveness_props, - ); - current_action = Some(Action { - name: String::new(), - kind: "internal".into(), - from: Vec::new(), - to: None, - guard: Vec::new(), - effect: Vec::new(), - params: Vec::new(), - hint: None, - }); - current_section = "action"; - continue; - } - if trimmed == "[[invariant]]" { - flush_all( - &mut current_action, - &mut actions, - &mut current_invariant, - &mut invariants, - &mut current_state_var, - &mut state_vars, - &mut current_liveness, - &mut liveness_props, - ); - current_invariant = Some(Invariant { - name: String::new(), - when: Vec::new(), - assert: String::new(), - }); - current_section = "invariant"; - continue; - } - if trimmed == "[[liveness]]" { - flush_all( - &mut current_action, - &mut actions, - &mut current_invariant, - &mut invariants, - &mut current_state_var, - &mut state_vars, - &mut current_liveness, - &mut liveness_props, - ); - if let Some(ig) = current_integration.take() - && !ig.name.is_empty() - { - integrations.push(ig); - } - current_liveness = Some(Liveness { - name: String::new(), - from: Vec::new(), - reaches: Vec::new(), - has_actions: None, - }); - current_section = "liveness"; - continue; - } - if trimmed == "[[integration]]" { - flush_all( - &mut current_action, - &mut actions, - &mut current_invariant, - &mut invariants, - &mut current_state_var, - &mut state_vars, - &mut current_liveness, - &mut liveness_props, - ); - if let Some(ig) = current_integration.take() - && !ig.name.is_empty() - { - integrations.push(ig); - } - current_integration = Some(Integration { - name: String::new(), - trigger: String::new(), - integration_type: "webhook".to_string(), - module: None, - on_success: None, - on_failure: None, - config: std::collections::BTreeMap::new(), - }); - current_section = "integration"; - continue; - } - if trimmed == "[[webhook]]" || trimmed.starts_with("[webhook.") { - flush_all( - &mut current_action, - &mut actions, - &mut current_invariant, - &mut invariants, - &mut current_state_var, - &mut state_vars, - &mut current_liveness, - &mut liveness_props, - ); - if let Some(ig) = current_integration.take() - && !ig.name.is_empty() - { - integrations.push(ig); - } - current_section = "webhook"; - continue; - } - - // Key-value pairs - if let Some((key, value)) = parse_kv(trimmed) { - match current_section { - "automaton" => match key { - "name" => meta_name = value.clone(), - "initial" => meta_initial = value.clone(), - "states" => meta_states = parse_string_array(&value), - _ => {} - }, - "state" => { - if let Some(ref mut sv) = current_state_var { - match key { - "name" => sv.name = value.clone(), - "type" => sv.var_type = value.clone(), - "initial" => sv.initial = value.clone(), - _ => {} - } - } - } - "action" => { - if let Some(ref mut a) = current_action { - match key { - "name" => a.name = value.clone(), - "kind" => a.kind = value.clone(), - "from" => a.from = parse_string_array(&value), - "to" => a.to = Some(value.clone()), - "params" => a.params = parse_string_array(&value), - "hint" => a.hint = Some(value.clone()), - "guard" => { - // Guard can be a string ("min count 2") or - // an array of inline tables ([{ type = "cross_entity_state", ... }]) - let gv = value.trim(); - if gv.starts_with('[') && gv.contains('{') { - parse_guard_array(gv, &mut a.guard)?; - } else { - a.guard.push(parse_guard_clause(gv)?); - } - } - "effect" => { - // Format: "increment var" → Increment - if value.starts_with("increment ") { - let var = value - .strip_prefix("increment ") - .unwrap_or("") - .trim() - .to_string(); - if !var.is_empty() { - a.effect.push(Effect::Increment { var }); - } - } - // Format: "decrement var" → Decrement - else if value.starts_with("decrement ") { - let var = value - .strip_prefix("decrement ") - .unwrap_or("") - .trim() - .to_string(); - if !var.is_empty() { - a.effect.push(Effect::Decrement { var }); - } - } - // Format: "set var true/false" → SetBool - else if value.starts_with("set ") { - let parts: Vec<&str> = value.splitn(3, ' ').collect(); - if parts.len() == 3 { - let var = parts[1].to_string(); - let val = parts[2].trim() == "true"; - a.effect.push(Effect::SetBool { var, value: val }); - } - } - // Format: "emit event_name" → Emit - else if value.starts_with("emit ") { - let event = value - .strip_prefix("emit ") - .unwrap_or("") - .trim() - .to_string(); - if !event.is_empty() { - a.effect.push(Effect::Emit { event }); - } - } - // Format: "trigger integration_name" → Trigger - else if value.starts_with("trigger ") { - let name = value - .strip_prefix("trigger ") - .unwrap_or("") - .trim() - .to_string(); - if !name.is_empty() { - a.effect.push(Effect::Trigger { name }); - } - } - // Format: array of inline tables [{ type = "schedule", ... }] - else if value.trim().starts_with('[') && value.contains('{') { - parse_effect_array(&value, &mut a.effect)?; - } - } - _ => {} - } - } - } - "invariant" => { - if let Some(ref mut inv) = current_invariant { - match key { - "name" => inv.name = value.clone(), - "when" => inv.when = parse_string_array(&value), - "assert" => inv.assert = value.clone(), - _ => {} - } - } - } - "liveness" => { - if let Some(ref mut l) = current_liveness { - match key { - "name" => l.name = value.clone(), - "from" => l.from = parse_string_array(&value), - "reaches" => l.reaches = parse_string_array(&value), - "has_actions" => l.has_actions = Some(value == "true"), - _ => {} - } - } - } - "integration" => { - if let Some(ref mut ig) = current_integration { - match key { - "name" => ig.name = value.clone(), - "trigger" => ig.trigger = value.clone(), - "type" => ig.integration_type = value.clone(), - "module" => ig.module = Some(value.clone()), - "on_success" => ig.on_success = Some(value.clone()), - "on_failure" => ig.on_failure = Some(value.clone()), - _ => { - ig.config.insert(key.to_string(), value.clone()); - } - } - } - } - _ => {} - } - } - } - - flush_all( - &mut current_action, - &mut actions, - &mut current_invariant, - &mut invariants, - &mut current_state_var, - &mut state_vars, - &mut current_liveness, - &mut liveness_props, - ); - if let Some(ig) = current_integration.take() - && !ig.name.is_empty() - { - integrations.push(ig); - } - - Ok(Automaton { - automaton: AutomatonMeta { - name: meta_name, - states: meta_states, - initial: meta_initial, - }, - state: state_vars, - actions, - invariants, - liveness: liveness_props, - integrations, - webhooks: extract_webhooks(input), - context_entities: Vec::new(), - agent_triggers: extract_agent_triggers(input), - }) -} - -/// Extract `[[webhook]]` sections from TOML source via serde. -/// -/// The hand-written parser does not handle `[[webhook]]` sections, so -/// we do a second pass with `toml::from_str` to deserialize them. -fn extract_webhooks(source: &str) -> Vec { - #[derive(serde::Deserialize)] - struct WebhookWrapper { - #[serde(default, rename = "webhook")] - webhooks: Vec, - } - toml::from_str::(source) - .map(|w| w.webhooks) - .unwrap_or_default() -} - -/// Extract `[[agent_trigger]]` sections from TOML source via serde. -fn extract_agent_triggers(source: &str) -> Vec { - #[derive(serde::Deserialize)] - struct AgentTriggerWrapper { - #[serde(default, rename = "agent_trigger")] - agent_triggers: Vec, - } - toml::from_str::(source) - .map(|w| w.agent_triggers) - .unwrap_or_default() -} - -#[allow(clippy::too_many_arguments)] -fn flush_all( - action: &mut Option, - actions: &mut Vec, - invariant: &mut Option, - invariants: &mut Vec, - state_var: &mut Option, - state_vars: &mut Vec, - liveness: &mut Option, - liveness_props: &mut Vec, -) { - if let Some(a) = action.take() - && !a.name.is_empty() - { - actions.push(a); - } - if let Some(inv) = invariant.take() - && !inv.name.is_empty() - { - invariants.push(inv); - } - if let Some(sv) = state_var.take() - && !sv.name.is_empty() - { - state_vars.push(sv); - } - if let Some(l) = liveness.take() - && !l.name.is_empty() - { - liveness_props.push(l); - } -} - -pub(super) fn parse_guard_clause(value: &str) -> Result { - let trimmed = value.trim(); - - // Infix forms: " >= ", " > ", " <= ", " < ". - // Check two-char operators before one-char to avoid mis-splitting ">=" on ">". - let infix_ops: &[(&str, bool)] = &[ - (">=", true), // (operator, is_min_guard) - ("<=", false), - (">", true), - ("<", false), - ]; - for &(op_str, is_min) in infix_ops { - if let Some(pos) = trimmed.find(op_str) { - let var = trimmed[..pos].trim(); - let raw = trimmed[pos + op_str.len()..].trim(); - if var.is_empty() || raw.is_empty() { - return Err(AutomatonParseError::Validation(format!( - "invalid guard '{trimmed}' (expected ' {op_str} ')" - ))); - } - let n: usize = raw.parse().map_err(|_| { - AutomatonParseError::Validation(format!( - "invalid guard '{trimmed}' (right side must be an integer)" - )) - })?; - if is_min { - // ">=" → MinCount { min: n }, ">" → MinCount { min: n + 1 } - let min = if op_str == ">=" { n } else { n + 1 }; - return Ok(Guard::MinCount { - var: var.to_string(), - min, - }); - } else { - // "<=" → MaxCount { max: n + 1 }, "<" → MaxCount { max: n } - let max = if op_str == "<=" { n + 1 } else { n }; - return Ok(Guard::MaxCount { - var: var.to_string(), - max, - }); - } - } - } - - // Negation prefix: "!var" → IsFalse { var } - if let Some(rest) = trimmed.strip_prefix('!') { - let var = rest.trim(); - if var.is_empty() || var.contains(' ') { - return Err(AutomatonParseError::Validation(format!( - "invalid guard '{trimmed}' (expected '!')" - ))); - } - return Ok(Guard::IsFalse { - var: var.to_string(), - }); - } - - // Prefix forms: - // - "min " - // - "max " - // - "is_true " - // - "is_false " - // - "list_contains " - // - "list_length_min " - let parts: Vec<&str> = trimmed.split_whitespace().collect(); - if parts.is_empty() { - return Err(AutomatonParseError::Validation( - "empty guard clause".to_string(), - )); - } - - match parts[0] { - "min" => { - if parts.len() != 3 { - return Err(AutomatonParseError::Validation(format!( - "invalid guard '{trimmed}' (expected 'min ')" - ))); - } - let min: usize = parts[2].parse().map_err(|_| { - AutomatonParseError::Validation(format!( - "invalid guard '{trimmed}' (min must be an integer)" - )) - })?; - Ok(Guard::MinCount { - var: parts[1].to_string(), - min, - }) - } - "max" => { - if parts.len() != 3 { - return Err(AutomatonParseError::Validation(format!( - "invalid guard '{trimmed}' (expected 'max ')" - ))); - } - let max: usize = parts[2].parse().map_err(|_| { - AutomatonParseError::Validation(format!( - "invalid guard '{trimmed}' (max must be an integer)" - )) - })?; - Ok(Guard::MaxCount { - var: parts[1].to_string(), - max, - }) - } - "is_true" => { - if parts.len() != 2 { - return Err(AutomatonParseError::Validation(format!( - "invalid guard '{trimmed}' (expected 'is_true ')" - ))); - } - Ok(Guard::IsTrue { - var: parts[1].to_string(), - }) - } - "is_false" => { - if parts.len() != 2 { - return Err(AutomatonParseError::Validation(format!( - "invalid guard '{trimmed}' (expected 'is_false ')" - ))); - } - Ok(Guard::IsFalse { - var: parts[1].to_string(), - }) - } - "list_contains" => { - if parts.len() < 3 { - return Err(AutomatonParseError::Validation(format!( - "invalid guard '{trimmed}' (expected 'list_contains ')" - ))); - } - Ok(Guard::ListContains { - var: parts[1].to_string(), - value: parts[2..].join(" "), - }) - } - "list_length_min" => { - if parts.len() != 3 { - return Err(AutomatonParseError::Validation(format!( - "invalid guard '{trimmed}' (expected 'list_length_min ')" - ))); - } - let min: usize = parts[2].parse().map_err(|_| { - AutomatonParseError::Validation(format!( - "invalid guard '{trimmed}' (min must be an integer)" - )) - })?; - Ok(Guard::ListLengthMin { - var: parts[1].to_string(), - min, - }) - } - // Bare identifier: single word with no operators → IsTrue { var } - _ if parts.len() == 1 && parts[0].chars().all(|c| c.is_alphanumeric() || c == '_') => { - Ok(Guard::IsTrue { - var: parts[0].to_string(), - }) - } - _ => Err(AutomatonParseError::Validation(format!( - "unsupported guard syntax '{trimmed}'" - ))), - } -} - -/// Parse an effect array in inline table format. -/// -/// Handles: `[{ type = "schedule", action = "Refresh", delay_seconds = 2700 }]` -fn parse_effect_array(value: &str, effects: &mut Vec) -> Result<(), AutomatonParseError> { - let trimmed = value.trim(); - if !trimmed.starts_with('[') || !trimmed.ends_with(']') { - return Ok(()); - } - let inner = &trimmed[1..trimmed.len() - 1]; - - // Split on "}, {" to separate inline table entries. - // Simple approach: iterate over inline tables delimited by braces. - for entry in split_inline_tables(inner) { - let entry = entry.trim().trim_matches('{').trim_matches('}').trim(); - let fields = parse_inline_fields(entry); - - let effect_type = fields.get("type").map(|s| s.as_str()).unwrap_or(""); - match effect_type { - "schedule" => { - let action = fields.get("action").cloned().unwrap_or_default(); - let delay_seconds: u64 = fields - .get("delay_seconds") - .and_then(|s| s.parse().ok()) - .unwrap_or(0); - if !action.is_empty() { - effects.push(Effect::Schedule { - action, - delay_seconds, - }); - } - } - "increment" => { - if let Some(var) = fields.get("var").cloned() { - effects.push(Effect::Increment { var }); - } - } - "decrement" => { - if let Some(var) = fields.get("var").cloned() { - effects.push(Effect::Decrement { var }); - } - } - "set_bool" => { - if let Some(var) = fields.get("var").cloned() { - let value = fields.get("value").map(|s| s == "true").unwrap_or(false); - effects.push(Effect::SetBool { var, value }); - } - } - "emit" | "emit_event" => { - if let Some(event) = fields.get("event").cloned() { - effects.push(Effect::Emit { event }); - } - } - "trigger" => { - if let Some(name) = fields.get("name").cloned() { - effects.push(Effect::Trigger { name }); - } - } - "list_append" => { - let var = fields - .get("var") - .cloned() - .or_else(|| fields.get("list").cloned()); - if let Some(var) = var { - effects.push(Effect::ListAppend { var }); - } - } - "list_remove_at" => { - let var = fields - .get("var") - .cloned() - .or_else(|| fields.get("list").cloned()); - if let Some(var) = var { - effects.push(Effect::ListRemoveAt { var }); - } - } - "spawn" | "spawn_entity" => { - let entity_type = fields.get("entity_type").cloned().unwrap_or_default(); - let entity_id_source = fields.get("entity_id_source").cloned().unwrap_or_default(); - let initial_action = fields.get("initial_action").cloned(); - let store_id_in = fields.get("store_id_in").cloned(); - if !entity_type.is_empty() { - effects.push(Effect::Spawn { - entity_type, - entity_id_source, - initial_action, - store_id_in, - }); - } - } - _ => { - return Err(AutomatonParseError::Validation(format!( - "unsupported effect type '{effect_type}'" - ))); - } - } - } - Ok(()) -} - -/// Parse a guard array in inline table format. -/// -/// Handles: `[{ type = "cross_entity_state", entity_type = "Child", ... }]` -fn parse_guard_array(value: &str, guards: &mut Vec) -> Result<(), AutomatonParseError> { - let trimmed = value.trim(); - if !trimmed.starts_with('[') || !trimmed.ends_with(']') { - return Ok(()); - } - let inner = &trimmed[1..trimmed.len() - 1]; - - for entry in split_inline_tables(inner) { - let entry = entry.trim().trim_matches('{').trim_matches('}').trim(); - let fields = parse_inline_fields(entry); - - let guard_type = fields.get("type").map(|s| s.as_str()).unwrap_or(""); - match guard_type { - "cross_entity_state" => { - let entity_type = fields.get("entity_type").cloned().unwrap_or_default(); - let entity_id_source = fields.get("entity_id_source").cloned().unwrap_or_default(); - let required_status = fields - .get("required_status") - .map(|s| parse_string_array(s)) - .unwrap_or_default(); - if !entity_type.is_empty() { - guards.push(Guard::CrossEntityState { - entity_type, - entity_id_source, - required_status, - }); - } - } - "state_in" => { - let values = fields - .get("values") - .map(|s| parse_string_array(s)) - .unwrap_or_default(); - guards.push(Guard::StateIn { values }); - } - "min_count" => { - let var = fields.get("var").cloned().unwrap_or_default(); - let min: usize = fields.get("min").and_then(|s| s.parse().ok()).unwrap_or(0); - guards.push(Guard::MinCount { var, min }); - } - "max_count" => { - let var = fields.get("var").cloned().unwrap_or_default(); - let max: usize = fields.get("max").and_then(|s| s.parse().ok()).unwrap_or(0); - guards.push(Guard::MaxCount { var, max }); - } - "is_true" => { - let var = fields.get("var").cloned().unwrap_or_default(); - guards.push(Guard::IsTrue { var }); - } - "is_false" => { - let var = fields.get("var").cloned().unwrap_or_default(); - guards.push(Guard::IsFalse { var }); - } - "list_contains" => { - let var = fields.get("var").cloned().unwrap_or_default(); - let value = fields.get("value").cloned().unwrap_or_default(); - guards.push(Guard::ListContains { var, value }); - } - "list_length_min" => { - let var = fields.get("var").cloned().unwrap_or_default(); - let min: usize = fields.get("min").and_then(|s| s.parse().ok()).unwrap_or(0); - guards.push(Guard::ListLengthMin { var, min }); - } - _ => { - return Err(AutomatonParseError::Validation(format!( - "unsupported guard type '{guard_type}'" - ))); - } - } - } - Ok(()) -} - -/// Split inline tables from a TOML array (e.g., "{a = 1}, {b = 2}"). -/// -/// Each returned slice starts at `{` and ends at `}`, excluding any -/// separator characters (`, `) between entries. -fn split_inline_tables(s: &str) -> Vec<&str> { - let mut result = Vec::new(); - let mut depth: usize = 0; - let mut start = None; - let mut in_single_quote = false; - let mut in_double_quote = false; - let mut escaped = false; - - for (i, c) in s.char_indices() { - if in_double_quote && c == '\\' { - escaped = !escaped; - continue; - } - - if c == '"' && !in_single_quote && !escaped { - in_double_quote = !in_double_quote; - } else if c == '\'' && !in_double_quote { - in_single_quote = !in_single_quote; - } - - if c != '\\' { - escaped = false; - } - - if in_single_quote || in_double_quote { - continue; - } - - match c { - '{' => { - if depth == 0 { - start = Some(i); - } - depth += 1; - } - '}' => { - depth = depth.saturating_sub(1); - if depth == 0 - && let Some(start_idx) = start.take() - { - result.push(&s[start_idx..=i]); - } - } - _ => {} - } - } - result -} - -/// Parse key-value pairs from an inline table (e.g., "type = "schedule", action = "Refresh""). -fn parse_inline_fields(s: &str) -> std::collections::BTreeMap { - let mut map = std::collections::BTreeMap::new(); - for pair in s.split(',') { - let pair = pair.trim(); - if let Some(eq_pos) = pair.find('=') { - let key = pair[..eq_pos].trim().to_string(); - let val = pair[eq_pos + 1..] - .trim() - .trim_matches('"') - .trim_matches('\'') - .to_string(); - map.insert(key, val); - } - } - map -} - -pub(super) fn parse_kv(line: &str) -> Option<(&str, String)> { - let eq = line.find('=')?; - let key = line[..eq].trim(); - let raw_value = line[eq + 1..].trim(); - let value = raw_value.trim_matches('"').trim_matches('\'').to_string(); - Some((key, value)) -} - -pub(super) fn parse_string_array(value: &str) -> Vec { - let trimmed = value.trim(); - if trimmed.starts_with('[') && trimmed.ends_with(']') { - let inner = &trimmed[1..trimmed.len() - 1]; - inner - .split(',') - .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string()) - .filter(|s| !s.is_empty()) - .collect() - } else { - vec![trimmed.trim_matches('"').to_string()] - } -} - -/// Join multiline array values into single logical lines. -/// -/// When a TOML line has unbalanced brackets (e.g., `effect = [`), this -/// function accumulates subsequent lines until brackets are balanced, -/// producing a single logical line for the parser. -fn join_multiline_arrays(input: &str) -> Vec { - let mut result = Vec::new(); - let mut buffer = String::new(); - let mut bracket_depth: i32 = 0; - - for line in input.lines() { - let trimmed = line.trim(); - - if bracket_depth > 0 { - // We're inside a multiline value — accumulate - buffer.push(' '); - buffer.push_str(trimmed); - for ch in trimmed.chars() { - match ch { - '[' => bracket_depth += 1, - ']' => bracket_depth -= 1, - _ => {} - } - } - if bracket_depth <= 0 { - result.push(std::mem::take(&mut buffer)); - bracket_depth = 0; - } - continue; - } - - // Check if this line opens an unbalanced bracket - let mut depth: i32 = 0; - // Only count brackets in the value part (after '=') - let value_part = if let Some(eq_pos) = trimmed.find('=') { - &trimmed[eq_pos + 1..] - } else { - trimmed - }; - for ch in value_part.chars() { - match ch { - '[' => depth += 1, - ']' => depth -= 1, - _ => {} - } - } - - if depth > 0 { - // Unbalanced — start buffering - buffer = trimmed.to_string(); - bracket_depth = depth; - } else { - result.push(trimmed.to_string()); - } - } - - // If buffer is non-empty (malformed input), flush it - if !buffer.is_empty() { - result.push(buffer); - } - - result -} - -#[cfg(test)] -#[path = "toml_parser_tests.rs"] -mod tests; diff --git a/crates/temper-spec/src/automaton/toml_parser/effects.rs b/crates/temper-spec/src/automaton/toml_parser/effects.rs new file mode 100644 index 00000000..b80d4f31 --- /dev/null +++ b/crates/temper-spec/src/automaton/toml_parser/effects.rs @@ -0,0 +1,152 @@ +use super::AutomatonParseError; +use super::inline::{parse_inline_fields, split_inline_tables}; +use crate::automaton::Effect; + +pub(super) fn parse_effect_value( + value: &str, + effects: &mut Vec, +) -> Result<(), AutomatonParseError> { + let trimmed = value.trim(); + + if trimmed.starts_with('[') && trimmed.contains('{') { + return parse_effect_array(trimmed, effects); + } + + if let Some(effect) = parse_legacy_effect(trimmed) { + effects.push(effect); + } + + Ok(()) +} + +fn parse_effect_array(value: &str, effects: &mut Vec) -> Result<(), AutomatonParseError> { + let trimmed = value.trim(); + if !trimmed.starts_with('[') || !trimmed.ends_with(']') { + return Ok(()); + } + + let inner = &trimmed[1..trimmed.len() - 1]; + for entry in split_inline_tables(inner) { + let entry = entry.trim().trim_matches('{').trim_matches('}').trim(); + let fields = parse_inline_fields(entry); + + if let Some(effect) = parse_effect_fields(&fields)? { + effects.push(effect); + } + } + + Ok(()) +} + +fn parse_effect_fields( + fields: &std::collections::BTreeMap, +) -> Result, AutomatonParseError> { + let effect_type = fields.get("type").map(|s| s.as_str()).unwrap_or(""); + + let effect = match effect_type { + "schedule" => { + let action = fields.get("action").cloned().unwrap_or_default(); + if action.is_empty() { + None + } else { + let delay_seconds = fields + .get("delay_seconds") + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + Some(Effect::Schedule { + action, + delay_seconds, + }) + } + } + "increment" => fields + .get("var") + .cloned() + .map(|var| Effect::Increment { var }), + "decrement" => fields + .get("var") + .cloned() + .map(|var| Effect::Decrement { var }), + "set_bool" => fields.get("var").cloned().map(|var| Effect::SetBool { + var, + value: fields.get("value").is_some_and(|s| s == "true"), + }), + "emit" | "emit_event" => fields + .get("event") + .cloned() + .map(|event| Effect::Emit { event }), + "trigger" => fields + .get("name") + .cloned() + .map(|name| Effect::Trigger { name }), + "list_append" => list_var(fields).map(|var| Effect::ListAppend { var }), + "list_remove_at" => list_var(fields).map(|var| Effect::ListRemoveAt { var }), + "spawn" | "spawn_entity" => { + let entity_type = fields.get("entity_type").cloned().unwrap_or_default(); + if entity_type.is_empty() { + None + } else { + Some(Effect::Spawn { + entity_type, + entity_id_source: fields.get("entity_id_source").cloned().unwrap_or_default(), + initial_action: fields.get("initial_action").cloned(), + store_id_in: fields.get("store_id_in").cloned(), + }) + } + } + _ => { + return Err(AutomatonParseError::Validation(format!( + "unsupported effect type '{effect_type}'" + ))); + } + }; + + Ok(effect) +} + +fn parse_legacy_effect(value: &str) -> Option { + if let Some(var) = parse_prefixed_identifier(value, "increment ") { + return Some(Effect::Increment { var }); + } + + if let Some(var) = parse_prefixed_identifier(value, "decrement ") { + return Some(Effect::Decrement { var }); + } + + if let Some((var, bool_value)) = parse_bool_set(value) { + return Some(Effect::SetBool { + var, + value: bool_value, + }); + } + + if let Some(event) = parse_prefixed_identifier(value, "emit ") { + return Some(Effect::Emit { event }); + } + + parse_prefixed_identifier(value, "trigger ").map(|name| Effect::Trigger { name }) +} + +fn parse_prefixed_identifier(value: &str, prefix: &str) -> Option { + value + .strip_prefix(prefix) + .map(str::trim) + .filter(|candidate| !candidate.is_empty()) + .map(ToOwned::to_owned) +} + +fn parse_bool_set(value: &str) -> Option<(String, bool)> { + let parts: Vec<&str> = value.splitn(3, ' ').collect(); + if parts.len() != 3 || parts[0] != "set" { + return None; + } + + Some((parts[1].to_string(), parts[2].trim() == "true")) +} + +fn list_var(fields: &std::collections::BTreeMap) -> Option { + fields + .get("var") + .cloned() + .or_else(|| fields.get("list").cloned()) +} diff --git a/crates/temper-spec/src/automaton/toml_parser/guards.rs b/crates/temper-spec/src/automaton/toml_parser/guards.rs new file mode 100644 index 00000000..e0b4a465 --- /dev/null +++ b/crates/temper-spec/src/automaton/toml_parser/guards.rs @@ -0,0 +1,246 @@ +use super::AutomatonParseError; +use super::inline::{parse_inline_fields, parse_string_array, split_inline_tables}; +use crate::automaton::Guard; + +pub(super) fn parse_guard_value( + value: &str, + guards: &mut Vec, +) -> Result<(), AutomatonParseError> { + let trimmed = value.trim(); + + if trimmed.starts_with('[') && trimmed.contains('{') { + return parse_guard_array(trimmed, guards); + } + + guards.push(parse_guard_clause(trimmed)?); + Ok(()) +} + +pub(super) fn parse_guard_clause(value: &str) -> Result { + let trimmed = value.trim(); + + for &(operator, is_min_guard) in &[(">=", true), ("<=", false), (">", true), ("<", false)] { + if let Some(pos) = trimmed.find(operator) { + return parse_infix_guard(trimmed, operator, pos, is_min_guard); + } + } + + if let Some(rest) = trimmed.strip_prefix('!') { + return parse_negated_guard(trimmed, rest); + } + + parse_prefix_guard(trimmed) +} + +fn parse_guard_array(value: &str, guards: &mut Vec) -> Result<(), AutomatonParseError> { + let trimmed = value.trim(); + if !trimmed.starts_with('[') || !trimmed.ends_with(']') { + return Ok(()); + } + + let inner = &trimmed[1..trimmed.len() - 1]; + for entry in split_inline_tables(inner) { + let entry = entry.trim().trim_matches('{').trim_matches('}').trim(); + guards.push(parse_guard_fields(&parse_inline_fields(entry))?); + } + + Ok(()) +} + +fn parse_guard_fields( + fields: &std::collections::BTreeMap, +) -> Result { + let guard_type = fields.get("type").map(|s| s.as_str()).unwrap_or(""); + + let guard = match guard_type { + "cross_entity_state" => Guard::CrossEntityState { + entity_type: fields.get("entity_type").cloned().unwrap_or_default(), + entity_id_source: fields.get("entity_id_source").cloned().unwrap_or_default(), + required_status: fields + .get("required_status") + .map(|s| parse_string_array(s)) + .unwrap_or_default(), + }, + "state_in" => Guard::StateIn { + values: fields + .get("values") + .map(|s| parse_string_array(s)) + .unwrap_or_default(), + }, + "min_count" => Guard::MinCount { + var: fields.get("var").cloned().unwrap_or_default(), + min: fields.get("min").and_then(|s| s.parse().ok()).unwrap_or(0), + }, + "max_count" => Guard::MaxCount { + var: fields.get("var").cloned().unwrap_or_default(), + max: fields.get("max").and_then(|s| s.parse().ok()).unwrap_or(0), + }, + "is_true" => Guard::IsTrue { + var: fields.get("var").cloned().unwrap_or_default(), + }, + "is_false" => Guard::IsFalse { + var: fields.get("var").cloned().unwrap_or_default(), + }, + "list_contains" => Guard::ListContains { + var: fields.get("var").cloned().unwrap_or_default(), + value: fields.get("value").cloned().unwrap_or_default(), + }, + "list_length_min" => Guard::ListLengthMin { + var: fields.get("var").cloned().unwrap_or_default(), + min: fields.get("min").and_then(|s| s.parse().ok()).unwrap_or(0), + }, + _ => { + return Err(AutomatonParseError::Validation(format!( + "unsupported guard type '{guard_type}'" + ))); + } + }; + + Ok(guard) +} + +fn parse_infix_guard( + trimmed: &str, + operator: &str, + position: usize, + is_min_guard: bool, +) -> Result { + let var = trimmed[..position].trim(); + let raw = trimmed[position + operator.len()..].trim(); + if var.is_empty() || raw.is_empty() { + return Err(AutomatonParseError::Validation(format!( + "invalid guard '{trimmed}' (expected ' {operator} ')" + ))); + } + + let number = raw.parse::().map_err(|_| { + AutomatonParseError::Validation(format!( + "invalid guard '{trimmed}' (right side must be an integer)" + )) + })?; + + if is_min_guard { + let min = if operator == ">=" { number } else { number + 1 }; + return Ok(Guard::MinCount { + var: var.to_string(), + min, + }); + } + + let max = if operator == "<=" { number + 1 } else { number }; + Ok(Guard::MaxCount { + var: var.to_string(), + max, + }) +} + +fn parse_negated_guard(trimmed: &str, rest: &str) -> Result { + let var = rest.trim(); + if var.is_empty() || var.contains(' ') { + return Err(AutomatonParseError::Validation(format!( + "invalid guard '{trimmed}' (expected '!')" + ))); + } + + Ok(Guard::IsFalse { + var: var.to_string(), + }) +} + +fn parse_prefix_guard(trimmed: &str) -> Result { + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.is_empty() { + return Err(AutomatonParseError::Validation( + "empty guard clause".to_string(), + )); + } + + match parts[0] { + "min" => Ok(Guard::MinCount { + var: parts + .get(1) + .ok_or_else(|| invalid_guard(trimmed, "expected 'min '"))? + .to_string(), + min: parse_usize_arg(trimmed, parts.get(2), "min must be an integer")?, + }), + "max" => Ok(Guard::MaxCount { + var: parts + .get(1) + .ok_or_else(|| invalid_guard(trimmed, "expected 'max '"))? + .to_string(), + max: parse_usize_arg(trimmed, parts.get(2), "max must be an integer")?, + }), + "is_true" => parse_boolean_guard(trimmed, &parts, true), + "is_false" => parse_boolean_guard(trimmed, &parts, false), + "list_contains" => { + if parts.len() < 3 { + return Err(invalid_guard( + trimmed, + "expected 'list_contains '", + )); + } + Ok(Guard::ListContains { + var: parts[1].to_string(), + value: parts[2..].join(" "), + }) + } + "list_length_min" => Ok(Guard::ListLengthMin { + var: parts + .get(1) + .ok_or_else(|| invalid_guard(trimmed, "expected 'list_length_min '"))? + .to_string(), + min: parse_usize_arg(trimmed, parts.get(2), "min must be an integer")?, + }), + _ if parts.len() == 1 && parts[0].chars().all(|c| c.is_alphanumeric() || c == '_') => { + Ok(Guard::IsTrue { + var: parts[0].to_string(), + }) + } + _ => Err(AutomatonParseError::Validation(format!( + "unsupported guard syntax '{trimmed}'" + ))), + } +} + +fn parse_boolean_guard( + trimmed: &str, + parts: &[&str], + expected_true: bool, +) -> Result { + if parts.len() != 2 { + let expected = if expected_true { + "expected 'is_true '" + } else { + "expected 'is_false '" + }; + return Err(invalid_guard(trimmed, expected)); + } + + Ok(if expected_true { + Guard::IsTrue { + var: parts[1].to_string(), + } + } else { + Guard::IsFalse { + var: parts[1].to_string(), + } + }) +} + +fn parse_usize_arg( + trimmed: &str, + value: Option<&&str>, + message: &str, +) -> Result { + let Some(value) = value else { + return Err(invalid_guard(trimmed, "expected ' '")); + }; + + value.parse().map_err(|_| { + AutomatonParseError::Validation(format!("invalid guard '{trimmed}' ({message})")) + }) +} + +fn invalid_guard(trimmed: &str, message: &str) -> AutomatonParseError { + AutomatonParseError::Validation(format!("invalid guard '{trimmed}' ({message})")) +} diff --git a/crates/temper-spec/src/automaton/toml_parser/inline.rs b/crates/temper-spec/src/automaton/toml_parser/inline.rs new file mode 100644 index 00000000..e1b0edbc --- /dev/null +++ b/crates/temper-spec/src/automaton/toml_parser/inline.rs @@ -0,0 +1,186 @@ +pub(super) fn parse_kv(line: &str) -> Option<(&str, String)> { + let eq = line.find('=')?; + let key = line[..eq].trim(); + let raw_value = line[eq + 1..].trim(); + let value = raw_value.trim_matches('"').trim_matches('\'').to_string(); + Some((key, value)) +} + +pub(super) fn parse_string_array(value: &str) -> Vec { + let trimmed = value.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + let inner = &trimmed[1..trimmed.len() - 1]; + return split_top_level(inner, ',') + .into_iter() + .map(|item| item.trim().trim_matches('"').trim_matches('\'').to_string()) + .filter(|item| !item.is_empty()) + .collect(); + } + + vec![trimmed.trim_matches('"').trim_matches('\'').to_string()] +} + +pub(super) fn split_inline_tables(s: &str) -> Vec<&str> { + let mut result = Vec::new(); + let mut depth: usize = 0; + let mut start = None; + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut escaped = false; + + for (index, ch) in s.char_indices() { + if in_double_quote && ch == '\\' { + escaped = !escaped; + continue; + } + + if ch == '"' && !in_single_quote && !escaped { + in_double_quote = !in_double_quote; + } else if ch == '\'' && !in_double_quote { + in_single_quote = !in_single_quote; + } + + if ch != '\\' { + escaped = false; + } + + if in_single_quote || in_double_quote { + continue; + } + + match ch { + '{' => { + if depth == 0 { + start = Some(index); + } + depth += 1; + } + '}' => { + depth = depth.saturating_sub(1); + if depth == 0 + && let Some(start_index) = start.take() + { + result.push(&s[start_index..=index]); + } + } + _ => {} + } + } + + result +} + +pub(super) fn parse_inline_fields(s: &str) -> std::collections::BTreeMap { + let mut map = std::collections::BTreeMap::new(); + for pair in split_top_level(s, ',') { + let pair = pair.trim(); + if let Some(eq_pos) = pair.find('=') { + let key = pair[..eq_pos].trim().to_string(); + let val = pair[eq_pos + 1..] + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(); + map.insert(key, val); + } + } + map +} + +/// Join multiline array values into single logical lines. +/// +/// When a TOML line has unbalanced brackets (e.g., `effect = [`), this +/// function accumulates subsequent lines until brackets are balanced, +/// producing a single logical line for the parser. +pub(super) fn join_multiline_arrays(input: &str) -> Vec { + let mut result = Vec::new(); + let mut buffer = String::new(); + let mut bracket_depth: i32 = 0; + + for line in input.lines() { + let trimmed = line.trim(); + + if bracket_depth > 0 { + buffer.push(' '); + buffer.push_str(trimmed); + bracket_depth += net_bracket_depth(trimmed); + if bracket_depth <= 0 { + result.push(std::mem::take(&mut buffer)); + bracket_depth = 0; + } + continue; + } + + let value_part = trimmed + .find('=') + .map(|eq_pos| &trimmed[eq_pos + 1..]) + .unwrap_or(trimmed); + let depth = net_bracket_depth(value_part); + if depth > 0 { + buffer = trimmed.to_string(); + bracket_depth = depth; + } else { + result.push(trimmed.to_string()); + } + } + + if !buffer.is_empty() { + result.push(buffer); + } + + result +} + +fn split_top_level(s: &str, delimiter: char) -> Vec<&str> { + let mut result = Vec::new(); + let mut start = 0; + let mut bracket_depth = 0_i32; + let mut brace_depth = 0_i32; + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut escaped = false; + + for (index, ch) in s.char_indices() { + if in_double_quote && ch == '\\' { + escaped = !escaped; + continue; + } + + if ch == '"' && !in_single_quote && !escaped { + in_double_quote = !in_double_quote; + } else if ch == '\'' && !in_double_quote { + in_single_quote = !in_single_quote; + } + + if ch != '\\' { + escaped = false; + } + + if in_single_quote || in_double_quote { + continue; + } + + match ch { + '[' => bracket_depth += 1, + ']' => bracket_depth -= 1, + '{' => brace_depth += 1, + '}' => brace_depth -= 1, + _ if ch == delimiter && bracket_depth == 0 && brace_depth == 0 => { + result.push(&s[start..index]); + start = index + ch.len_utf8(); + } + _ => {} + } + } + + result.push(&s[start..]); + result +} + +fn net_bracket_depth(value: &str) -> i32 { + value.chars().fold(0, |depth, ch| match ch { + '[' => depth + 1, + ']' => depth - 1, + _ => depth, + }) +} diff --git a/crates/temper-spec/src/automaton/toml_parser/mod.rs b/crates/temper-spec/src/automaton/toml_parser/mod.rs new file mode 100644 index 00000000..0350410b --- /dev/null +++ b/crates/temper-spec/src/automaton/toml_parser/mod.rs @@ -0,0 +1,368 @@ +//! Minimal TOML parser for I/O Automaton specifications. +//! +//! Handles the subset of TOML used by IOA specs since we use a hand-rolled +//! parser rather than the full `toml` crate for the core parsing. Webhook +//! sections are delegated to `toml::from_str` in a second pass. + +mod effects; +mod guards; +mod inline; + +use super::parser::AutomatonParseError; +use super::types::*; +use effects::parse_effect_value; +#[cfg(test)] +use guards::parse_guard_clause; +use guards::parse_guard_value; +use inline::{join_multiline_arrays, parse_kv, parse_string_array}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum Section { + #[default] + None, + Automaton, + State, + Action, + Invariant, + Liveness, + Integration, + Webhook, +} + +#[derive(Debug, Default)] +struct ParseState { + meta_name: String, + meta_states: Vec, + meta_initial: String, + state_vars: Vec, + actions: Vec, + invariants: Vec, + liveness_props: Vec, + integrations: Vec, + current_section: Section, + current_action: Option, + current_invariant: Option, + current_state_var: Option, + current_liveness: Option, + current_integration: Option, +} + +impl ParseState { + fn enter_section(&mut self, line: &str) -> bool { + match line { + "[automaton]" => self.start_section(Section::Automaton), + "[[state]]" => self.start_state_section(), + "[[action]]" => self.start_action_section(), + "[[invariant]]" => self.start_invariant_section(), + "[[liveness]]" => self.start_liveness_section(), + "[[integration]]" => self.start_integration_section(), + "[[webhook]]" => self.start_webhook_section(), + _ if line.starts_with("[webhook.") => self.start_webhook_section(), + _ => false, + } + } + + fn apply_kv(&mut self, key: &str, value: String) -> Result<(), AutomatonParseError> { + match self.current_section { + Section::Automaton => self.apply_automaton_field(key, &value), + Section::State => self.apply_state_field(key, &value), + Section::Action => self.apply_action_field(key, &value)?, + Section::Invariant => self.apply_invariant_field(key, &value), + Section::Liveness => self.apply_liveness_field(key, &value), + Section::Integration => self.apply_integration_field(key, &value), + Section::Webhook | Section::None => {} + } + + Ok(()) + } + + fn finish(mut self, input: &str) -> Automaton { + self.flush_items(); + self.flush_integration(); + + debug_assert!(self.current_action.is_none()); + debug_assert!(self.current_invariant.is_none()); + debug_assert!(self.current_state_var.is_none()); + debug_assert!(self.current_liveness.is_none()); + debug_assert!(self.current_integration.is_none()); + + Automaton { + automaton: AutomatonMeta { + name: self.meta_name, + states: self.meta_states, + initial: self.meta_initial, + }, + state: self.state_vars, + actions: self.actions, + invariants: self.invariants, + liveness: self.liveness_props, + integrations: self.integrations, + webhooks: extract_webhooks(input), + context_entities: Vec::new(), + agent_triggers: extract_agent_triggers(input), + } + } + + fn apply_automaton_field(&mut self, key: &str, value: &str) { + match key { + "name" => self.meta_name = value.to_string(), + "initial" => self.meta_initial = value.to_string(), + "states" => self.meta_states = parse_string_array(value), + _ => {} + } + } + + fn apply_state_field(&mut self, key: &str, value: &str) { + let Some(state_var) = self.current_state_var.as_mut() else { + return; + }; + + match key { + "name" => state_var.name = value.to_string(), + "type" => state_var.var_type = value.to_string(), + "initial" => state_var.initial = value.to_string(), + _ => {} + } + } + + fn apply_action_field(&mut self, key: &str, value: &str) -> Result<(), AutomatonParseError> { + let Some(action) = self.current_action.as_mut() else { + return Ok(()); + }; + + match key { + "name" => action.name = value.to_string(), + "kind" => action.kind = value.to_string(), + "from" => action.from = parse_string_array(value), + "to" => action.to = Some(value.to_string()), + "params" => action.params = parse_string_array(value), + "hint" => action.hint = Some(value.to_string()), + "guard" => parse_guard_value(value, &mut action.guard)?, + "effect" => parse_effect_value(value, &mut action.effect)?, + _ => {} + } + + Ok(()) + } + + fn apply_invariant_field(&mut self, key: &str, value: &str) { + let Some(invariant) = self.current_invariant.as_mut() else { + return; + }; + + match key { + "name" => invariant.name = value.to_string(), + "when" => invariant.when = parse_string_array(value), + "assert" => invariant.assert = value.to_string(), + _ => {} + } + } + + fn apply_liveness_field(&mut self, key: &str, value: &str) { + let Some(liveness) = self.current_liveness.as_mut() else { + return; + }; + + match key { + "name" => liveness.name = value.to_string(), + "from" => liveness.from = parse_string_array(value), + "reaches" => liveness.reaches = parse_string_array(value), + "has_actions" => liveness.has_actions = Some(value == "true"), + _ => {} + } + } + + fn apply_integration_field(&mut self, key: &str, value: &str) { + let Some(integration) = self.current_integration.as_mut() else { + return; + }; + + match key { + "name" => integration.name = value.to_string(), + "trigger" => integration.trigger = value.to_string(), + "type" => integration.integration_type = value.to_string(), + "module" => integration.module = Some(value.to_string()), + "on_success" => integration.on_success = Some(value.to_string()), + "on_failure" => integration.on_failure = Some(value.to_string()), + _ => { + integration + .config + .insert(key.to_string(), value.to_string()); + } + } + } + + fn flush_items(&mut self) { + if let Some(action) = self.current_action.take() + && !action.name.is_empty() + { + self.actions.push(action); + } + + if let Some(invariant) = self.current_invariant.take() + && !invariant.name.is_empty() + { + self.invariants.push(invariant); + } + + if let Some(state_var) = self.current_state_var.take() + && !state_var.name.is_empty() + { + self.state_vars.push(state_var); + } + + if let Some(liveness) = self.current_liveness.take() + && !liveness.name.is_empty() + { + self.liveness_props.push(liveness); + } + } + + fn flush_integration(&mut self) { + if let Some(integration) = self.current_integration.take() + && !integration.name.is_empty() + { + self.integrations.push(integration); + } + } + + fn start_section(&mut self, section: Section) -> bool { + self.flush_items(); + self.current_section = section; + true + } + + fn start_state_section(&mut self) -> bool { + self.flush_items(); + self.current_state_var = Some(StateVar { + name: String::new(), + var_type: "string".into(), + initial: String::new(), + }); + self.current_section = Section::State; + true + } + + fn start_action_section(&mut self) -> bool { + self.flush_items(); + self.current_action = Some(Action { + name: String::new(), + kind: "internal".into(), + from: Vec::new(), + to: None, + guard: Vec::new(), + effect: Vec::new(), + params: Vec::new(), + hint: None, + }); + self.current_section = Section::Action; + true + } + + fn start_invariant_section(&mut self) -> bool { + self.flush_items(); + self.current_invariant = Some(Invariant { + name: String::new(), + when: Vec::new(), + assert: String::new(), + }); + self.current_section = Section::Invariant; + true + } + + fn start_liveness_section(&mut self) -> bool { + self.flush_items(); + self.flush_integration(); + self.current_liveness = Some(Liveness { + name: String::new(), + from: Vec::new(), + reaches: Vec::new(), + has_actions: None, + }); + self.current_section = Section::Liveness; + true + } + + fn start_integration_section(&mut self) -> bool { + self.flush_items(); + self.flush_integration(); + self.current_integration = Some(Integration { + name: String::new(), + trigger: String::new(), + integration_type: "webhook".to_string(), + module: None, + on_success: None, + on_failure: None, + config: std::collections::BTreeMap::new(), + }); + self.current_section = Section::Integration; + true + } + + fn start_webhook_section(&mut self) -> bool { + self.flush_items(); + self.flush_integration(); + self.current_section = Section::Webhook; + true + } +} + +/// Parse TOML into an Automaton struct. +/// +/// This is a minimal parser that handles the subset of TOML we use: +/// - `[automaton]` table with name, states, initial +/// - `[[action]]` array of tables +/// - `[[invariant]]` array of tables +/// - Simple key = "value" and key = ["array"] syntax +pub(super) fn parse_toml_to_automaton(input: &str) -> Result { + let mut state = ParseState::default(); + let logical_lines = join_multiline_arrays(input); + + for line in logical_lines { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + if state.enter_section(trimmed) { + continue; + } + + if let Some((key, value)) = parse_kv(trimmed) { + state.apply_kv(key, value)?; + } + } + + Ok(state.finish(input)) +} + +/// Extract `[[webhook]]` sections from TOML source via serde. +/// +/// The hand-written parser does not handle `[[webhook]]` sections, so +/// we do a second pass with `toml::from_str` to deserialize them. +fn extract_webhooks(source: &str) -> Vec { + #[derive(serde::Deserialize)] + struct WebhookWrapper { + #[serde(default, rename = "webhook")] + webhooks: Vec, + } + toml::from_str::(source) + .map(|w| w.webhooks) + .unwrap_or_default() +} + +/// Extract `[[agent_trigger]]` sections from TOML source via serde. +fn extract_agent_triggers(source: &str) -> Vec { + #[derive(serde::Deserialize)] + struct AgentTriggerWrapper { + #[serde(default, rename = "agent_trigger")] + agent_triggers: Vec, + } + toml::from_str::(source) + .map(|w| w.agent_triggers) + .unwrap_or_default() +} + +#[cfg(test)] +#[path = "test.rs"] +mod tests; diff --git a/crates/temper-spec/src/automaton/toml_parser_tests.rs b/crates/temper-spec/src/automaton/toml_parser/test.rs similarity index 92% rename from crates/temper-spec/src/automaton/toml_parser_tests.rs rename to crates/temper-spec/src/automaton/toml_parser/test.rs index f540a737..2338bdfa 100644 --- a/crates/temper-spec/src/automaton/toml_parser_tests.rs +++ b/crates/temper-spec/src/automaton/toml_parser/test.rs @@ -1,3 +1,4 @@ +use super::inline::{parse_inline_fields, split_inline_tables}; use super::*; // ── parse_kv ────────────────────────────────────────────── @@ -66,6 +67,18 @@ fn parse_inline_fields_simple() { assert_eq!(map.get("action").unwrap(), "Refresh"); } +#[test] +fn parse_inline_fields_keeps_nested_arrays_together() { + let map = parse_inline_fields( + "type = \"cross_entity_state\", required_status = [\"Draft\", \"Ready\"]", + ); + assert_eq!(map.get("type").unwrap(), "cross_entity_state"); + assert_eq!( + map.get("required_status").unwrap(), + "[\"Draft\", \"Ready\"]" + ); +} + #[test] fn parse_inline_fields_empty() { let map = parse_inline_fields(""); diff --git a/crates/temper-spec/src/automaton/translate.rs b/crates/temper-spec/src/automaton/translate.rs index 0a217846..d9b4c595 100644 --- a/crates/temper-spec/src/automaton/translate.rs +++ b/crates/temper-spec/src/automaton/translate.rs @@ -298,236 +298,5 @@ fn apply_name_heuristics( } #[cfg(test)] -mod tests { - use super::*; - use crate::automaton::parse_automaton; - - #[test] - fn translate_simple_action() { - let spec = r#" -[automaton] -name = "Test" -states = ["Draft", "Active"] -initial = "Draft" - -[[action]] -name = "Activate" -from = ["Draft"] -to = "Active" -"#; - let automaton = parse_automaton(spec).unwrap(); - let actions = translate_actions(&automaton); - assert_eq!(actions.len(), 1); - assert_eq!(actions[0].name, "Activate"); - assert_eq!( - actions[0].guard, - ResolvedGuard::StateIn(vec!["Draft".to_string()]) - ); - assert!(actions[0].effects.is_empty()); - } - - #[test] - fn translate_guards_combined() { - let spec = r#" -[automaton] -name = "Test" -states = ["Draft", "Active"] -initial = "Draft" - -[[state]] -name = "items" -type = "counter" -initial = "0" - -[[action]] -name = "Submit" -from = ["Draft"] -to = "Active" -guard = [{ type = "min_count", var = "items", min = 1 }] -"#; - let automaton = parse_automaton(spec).unwrap(); - let actions = translate_actions(&automaton); - let action = &actions[0]; - match &action.guard { - ResolvedGuard::And(guards) => { - assert_eq!(guards.len(), 2); - assert!(matches!(&guards[0], ResolvedGuard::StateIn(_))); - assert!(matches!( - &guards[1], - ResolvedGuard::CounterMin { var, min: 1 } if var == "items" - )); - } - _ => panic!("expected And guard, got {:?}", action.guard), - } - } - - #[test] - fn translate_effects_explicit() { - let spec = r#" -[automaton] -name = "Test" -states = ["Draft", "Active"] -initial = "Draft" - -[[action]] -name = "DoSomething" -from = ["Draft"] -to = "Active" -effect = [{ type = "increment", var = "count" }, { type = "set_bool", var = "done", value = true }, { type = "emit", event = "thing_done" }] -"#; - let automaton = parse_automaton(spec).unwrap(); - let actions = translate_actions(&automaton); - let effects = &actions[0].effects; - assert_eq!(effects.len(), 3); - assert!(matches!(&effects[0], ResolvedEffect::IncrementCounter(v) if v == "count")); - assert!( - matches!(&effects[1], ResolvedEffect::SetBool { var, value: true } if var == "done") - ); - assert!(matches!(&effects[2], ResolvedEffect::Emit(e) if e == "thing_done")); - } - - #[test] - fn translate_name_heuristic_additem() { - let spec = r#" -[automaton] -name = "Test" -states = ["Draft"] -initial = "Draft" - -[[state]] -name = "items" -type = "counter" -initial = "0" - -[[state]] -name = "quantity" -type = "counter" -initial = "0" - -[[action]] -name = "AddItem" -from = ["Draft"] -"#; - let automaton = parse_automaton(spec).unwrap(); - let actions = translate_actions(&automaton); - let effects = &actions[0].effects; - // Should have items increment + quantity increment - assert!(effects.len() >= 2); - assert!( - effects - .iter() - .any(|e| matches!(e, ResolvedEffect::IncrementCounter(v) if v == "items")) - ); - assert!( - effects - .iter() - .any(|e| matches!(e, ResolvedEffect::IncrementCounter(v) if v == "quantity")) - ); - } - - #[test] - fn translate_runtime_only_effects() { - let spec = r#" -[automaton] -name = "Test" -states = ["Idle", "Active"] -initial = "Idle" - -[[action]] -name = "Start" -from = ["Idle"] -to = "Active" -effect = [{ type = "trigger", name = "run_wasm" }, { type = "schedule", action = "Refresh", delay_seconds = 60 }, { type = "spawn", entity_type = "Child", entity_id_source = "{uuid}", initial_action = "Init" }] -"#; - let automaton = parse_automaton(spec).unwrap(); - let actions = translate_actions(&automaton); - let effects = &actions[0].effects; - assert_eq!(effects.len(), 3); - assert!(!effects[0].is_verifiable()); - assert!(!effects[1].is_verifiable()); - assert!(!effects[2].is_verifiable()); - } - - #[test] - fn translate_cross_entity_guard() { - let spec = r#" -[automaton] -name = "Parent" -states = ["Waiting", "Ready"] -initial = "Waiting" - -[[action]] -name = "Proceed" -from = ["Waiting"] -to = "Ready" -guard = [{ type = "cross_entity_state", entity_type = "Child", entity_id_source = "child_id", required_status = ["Done"] }] -"#; - let automaton = parse_automaton(spec).unwrap(); - let actions = translate_actions(&automaton); - let action = &actions[0]; - match &action.guard { - ResolvedGuard::And(guards) => { - let has_cross = guards.iter().any(|g| { - matches!(g, ResolvedGuard::CrossEntityState { entity_type, .. } if entity_type == "Child") - }); - assert!(has_cross, "expected CrossEntityState guard"); - } - _ => panic!("expected And guard"), - } - } - - #[test] - fn output_actions_filtered() { - let spec = r#" -[automaton] -name = "Test" -states = ["Draft"] -initial = "Draft" - -[[action]] -name = "Notify" -kind = "output" - -[[action]] -name = "DoWork" -from = ["Draft"] -"#; - let automaton = parse_automaton(spec).unwrap(); - let actions = translate_actions(&automaton); - assert_eq!(actions.len(), 1); - assert_eq!(actions[0].name, "DoWork"); - } - - #[test] - fn is_verifiable_classification() { - assert!(ResolvedEffect::IncrementCounter("x".into()).is_verifiable()); - assert!(ResolvedEffect::DecrementCounter("x".into()).is_verifiable()); - assert!( - ResolvedEffect::SetBool { - var: "x".into(), - value: true - } - .is_verifiable() - ); - assert!(ResolvedEffect::ListAppend("x".into()).is_verifiable()); - assert!(ResolvedEffect::ListRemoveAt("x".into()).is_verifiable()); - assert!(!ResolvedEffect::Emit("e".into()).is_verifiable()); - assert!(!ResolvedEffect::Trigger("t".into()).is_verifiable()); - assert!( - !ResolvedEffect::Schedule { - action: "a".into(), - delay_seconds: 1 - } - .is_verifiable() - ); - assert!( - !ResolvedEffect::Spawn { - entity_type: "T".into(), - entity_id_source: "s".into(), - initial_action: None, - store_id_in: None, - } - .is_verifiable() - ); - } -} +#[path = "translate_test.rs"] +mod tests; diff --git a/crates/temper-spec/src/automaton/translate_test.rs b/crates/temper-spec/src/automaton/translate_test.rs new file mode 100644 index 00000000..3b795f0d --- /dev/null +++ b/crates/temper-spec/src/automaton/translate_test.rs @@ -0,0 +1,231 @@ +use super::*; +use crate::automaton::parse_automaton; + +#[test] +fn translate_simple_action() { + let spec = r#" +[automaton] +name = "Test" +states = ["Draft", "Active"] +initial = "Draft" + +[[action]] +name = "Activate" +from = ["Draft"] +to = "Active" +"#; + let automaton = parse_automaton(spec).unwrap(); + let actions = translate_actions(&automaton); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].name, "Activate"); + assert_eq!( + actions[0].guard, + ResolvedGuard::StateIn(vec!["Draft".to_string()]) + ); + assert!(actions[0].effects.is_empty()); +} + +#[test] +fn translate_guards_combined() { + let spec = r#" +[automaton] +name = "Test" +states = ["Draft", "Active"] +initial = "Draft" + +[[state]] +name = "items" +type = "counter" +initial = "0" + +[[action]] +name = "Submit" +from = ["Draft"] +to = "Active" +guard = [{ type = "min_count", var = "items", min = 1 }] +"#; + let automaton = parse_automaton(spec).unwrap(); + let actions = translate_actions(&automaton); + let action = &actions[0]; + match &action.guard { + ResolvedGuard::And(guards) => { + assert_eq!(guards.len(), 2); + assert!(matches!(&guards[0], ResolvedGuard::StateIn(_))); + assert!(matches!( + &guards[1], + ResolvedGuard::CounterMin { var, min: 1 } if var == "items" + )); + } + _ => panic!("expected And guard, got {:?}", action.guard), + } +} + +#[test] +fn translate_effects_explicit() { + let spec = r#" +[automaton] +name = "Test" +states = ["Draft", "Active"] +initial = "Draft" + +[[action]] +name = "DoSomething" +from = ["Draft"] +to = "Active" +effect = [{ type = "increment", var = "count" }, { type = "set_bool", var = "done", value = true }, { type = "emit", event = "thing_done" }] +"#; + let automaton = parse_automaton(spec).unwrap(); + let actions = translate_actions(&automaton); + let effects = &actions[0].effects; + assert_eq!(effects.len(), 3); + assert!(matches!(&effects[0], ResolvedEffect::IncrementCounter(v) if v == "count")); + assert!(matches!( + &effects[1], + ResolvedEffect::SetBool { var, value: true } if var == "done" + )); + assert!(matches!(&effects[2], ResolvedEffect::Emit(e) if e == "thing_done")); +} + +#[test] +fn translate_name_heuristic_additem() { + let spec = r#" +[automaton] +name = "Test" +states = ["Draft"] +initial = "Draft" + +[[state]] +name = "items" +type = "counter" +initial = "0" + +[[state]] +name = "quantity" +type = "counter" +initial = "0" + +[[action]] +name = "AddItem" +from = ["Draft"] +"#; + let automaton = parse_automaton(spec).unwrap(); + let actions = translate_actions(&automaton); + let effects = &actions[0].effects; + assert!(effects.len() >= 2); + assert!( + effects + .iter() + .any(|effect| matches!(effect, ResolvedEffect::IncrementCounter(v) if v == "items")) + ); + assert!( + effects + .iter() + .any(|effect| matches!(effect, ResolvedEffect::IncrementCounter(v) if v == "quantity")) + ); +} + +#[test] +fn translate_runtime_only_effects() { + let spec = r#" +[automaton] +name = "Test" +states = ["Idle", "Active"] +initial = "Idle" + +[[action]] +name = "Start" +from = ["Idle"] +to = "Active" +effect = [{ type = "trigger", name = "run_wasm" }, { type = "schedule", action = "Refresh", delay_seconds = 60 }, { type = "spawn", entity_type = "Child", entity_id_source = "{uuid}", initial_action = "Init" }] +"#; + let automaton = parse_automaton(spec).unwrap(); + let actions = translate_actions(&automaton); + let effects = &actions[0].effects; + assert_eq!(effects.len(), 3); + assert!(!effects[0].is_verifiable()); + assert!(!effects[1].is_verifiable()); + assert!(!effects[2].is_verifiable()); +} + +#[test] +fn translate_cross_entity_guard() { + let spec = r#" +[automaton] +name = "Parent" +states = ["Waiting", "Ready"] +initial = "Waiting" + +[[action]] +name = "Proceed" +from = ["Waiting"] +to = "Ready" +guard = [{ type = "cross_entity_state", entity_type = "Child", entity_id_source = "child_id", required_status = ["Done"] }] +"#; + let automaton = parse_automaton(spec).unwrap(); + let actions = translate_actions(&automaton); + let action = &actions[0]; + match &action.guard { + ResolvedGuard::And(guards) => { + let has_cross = guards.iter().any(|guard| { + matches!(guard, ResolvedGuard::CrossEntityState { entity_type, .. } if entity_type == "Child") + }); + assert!(has_cross, "expected CrossEntityState guard"); + } + _ => panic!("expected And guard"), + } +} + +#[test] +fn output_actions_filtered() { + let spec = r#" +[automaton] +name = "Test" +states = ["Draft"] +initial = "Draft" + +[[action]] +name = "Notify" +kind = "output" + +[[action]] +name = "DoWork" +from = ["Draft"] +"#; + let automaton = parse_automaton(spec).unwrap(); + let actions = translate_actions(&automaton); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].name, "DoWork"); +} + +#[test] +fn is_verifiable_classification() { + assert!(ResolvedEffect::IncrementCounter("x".into()).is_verifiable()); + assert!(ResolvedEffect::DecrementCounter("x".into()).is_verifiable()); + assert!( + ResolvedEffect::SetBool { + var: "x".into(), + value: true + } + .is_verifiable() + ); + assert!(ResolvedEffect::ListAppend("x".into()).is_verifiable()); + assert!(ResolvedEffect::ListRemoveAt("x".into()).is_verifiable()); + assert!(!ResolvedEffect::Emit("e".into()).is_verifiable()); + assert!(!ResolvedEffect::Trigger("t".into()).is_verifiable()); + assert!( + !ResolvedEffect::Schedule { + action: "a".into(), + delay_seconds: 1 + } + .is_verifiable() + ); + assert!( + !ResolvedEffect::Spawn { + entity_type: "T".into(), + entity_id_source: "s".into(), + initial_action: None, + store_id_in: None, + } + .is_verifiable() + ); +} diff --git a/crates/temper-spec/src/automaton/types.rs b/crates/temper-spec/src/automaton/types.rs index 85261fd7..b4d6bec0 100644 --- a/crates/temper-spec/src/automaton/types.rs +++ b/crates/temper-spec/src/automaton/types.rs @@ -328,304 +328,5 @@ pub struct AgentTrigger { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_minimal_automaton() { - let toml_src = r#" -[automaton] -name = "Order" -states = ["Draft", "Active"] -initial = "Draft" -"#; - let a: Automaton = toml::from_str(toml_src).unwrap(); - assert_eq!(a.automaton.name, "Order"); - assert_eq!(a.automaton.states, vec!["Draft", "Active"]); - assert_eq!(a.automaton.initial, "Draft"); - assert!(a.actions.is_empty()); - assert!(a.invariants.is_empty()); - assert!(a.liveness.is_empty()); - assert!(a.integrations.is_empty()); - } - - #[test] - fn parse_action_defaults() { - let toml_src = r#" -[automaton] -name = "T" -states = ["A"] -initial = "A" - -[[action]] -name = "DoIt" -from = ["A"] -"#; - let a: Automaton = toml::from_str(toml_src).unwrap(); - assert_eq!(a.actions.len(), 1); - assert_eq!(a.actions[0].kind, "internal"); - assert!(a.actions[0].to.is_none()); - assert!(a.actions[0].guard.is_empty()); - assert!(a.actions[0].effect.is_empty()); - } - - #[test] - fn parse_guard_variants() { - let toml_src = r#" -[automaton] -name = "T" -states = ["A", "B"] -initial = "A" - -[[action]] -name = "G1" -from = ["A"] -to = "B" -guard = [ - { type = "min_count", var = "items", min = 1 }, - { type = "max_count", var = "items", max = 10 }, - { type = "is_true", var = "ready" }, - { type = "list_contains", var = "tags", value = "vip" }, - { type = "list_length_min", var = "tags", min = 2 }, -] -"#; - let a: Automaton = toml::from_str(toml_src).unwrap(); - let guards = &a.actions[0].guard; - assert_eq!(guards.len(), 5); - assert!(matches!(&guards[0], Guard::MinCount { var, min: 1 } if var == "items")); - assert!(matches!(&guards[1], Guard::MaxCount { var, max: 10 } if var == "items")); - assert!(matches!(&guards[2], Guard::IsTrue { var } if var == "ready")); - assert!( - matches!(&guards[3], Guard::ListContains { var, value } if var == "tags" && value == "vip") - ); - assert!(matches!(&guards[4], Guard::ListLengthMin { var, min: 2 } if var == "tags")); - } - - #[test] - fn parse_effect_variants() { - let toml_src = r#" -[automaton] -name = "T" -states = ["A"] -initial = "A" - -[[action]] -name = "E1" -from = ["A"] -effect = [ - { type = "increment", var = "count" }, - { type = "decrement", var = "count" }, - { type = "set_bool", var = "done", value = true }, - { type = "emit", event = "order_placed" }, - { type = "list_append", var = "log" }, - { type = "list_remove_at", var = "log" }, - { type = "trigger", name = "run_wasm" }, - { type = "schedule", action = "Retry", delay_seconds = 30 }, -] -"#; - let a: Automaton = toml::from_str(toml_src).unwrap(); - let effects = &a.actions[0].effect; - assert_eq!(effects.len(), 8); - assert!(matches!(&effects[0], Effect::Increment { var } if var == "count")); - assert!(matches!(&effects[1], Effect::Decrement { var } if var == "count")); - assert!(matches!(&effects[2], Effect::SetBool { var, value: true } if var == "done")); - assert!(matches!(&effects[3], Effect::Emit { event } if event == "order_placed")); - assert!(matches!(&effects[4], Effect::ListAppend { var } if var == "log")); - assert!(matches!(&effects[5], Effect::ListRemoveAt { var } if var == "log")); - assert!(matches!(&effects[6], Effect::Trigger { name } if name == "run_wasm")); - assert!( - matches!(&effects[7], Effect::Schedule { action, delay_seconds: 30 } if action == "Retry") - ); - } - - #[test] - fn parse_spawn_effect() { - let toml_src = r#" -[automaton] -name = "T" -states = ["A"] -initial = "A" - -[[action]] -name = "S1" -from = ["A"] -effect = [ - { type = "spawn", entity_type = "Child", entity_id_source = "{uuid}", initial_action = "Init", store_id_in = "child_id" }, -] -"#; - let a: Automaton = toml::from_str(toml_src).unwrap(); - match &a.actions[0].effect[0] { - Effect::Spawn { - entity_type, - entity_id_source, - initial_action, - store_id_in, - } => { - assert_eq!(entity_type, "Child"); - assert_eq!(entity_id_source, "{uuid}"); - assert_eq!(initial_action.as_deref(), Some("Init")); - assert_eq!(store_id_in.as_deref(), Some("child_id")); - } - other => panic!("expected Spawn, got {other:?}"), - } - } - - #[test] - fn parse_invariant_and_liveness() { - let toml_src = r#" -[automaton] -name = "T" -states = ["A", "B", "C"] -initial = "A" - -[[invariant]] -name = "NonNeg" -when = ["B"] -assert = "count >= 0" - -[[liveness]] -name = "Progress" -from = ["A"] -reaches = ["C"] -"#; - let a: Automaton = toml::from_str(toml_src).unwrap(); - assert_eq!(a.invariants.len(), 1); - assert_eq!(a.invariants[0].name, "NonNeg"); - assert_eq!(a.invariants[0].when, vec!["B"]); - assert_eq!(a.invariants[0].assert, "count >= 0"); - - assert_eq!(a.liveness.len(), 1); - assert_eq!(a.liveness[0].name, "Progress"); - assert_eq!(a.liveness[0].from, vec!["A"]); - assert_eq!(a.liveness[0].reaches, vec!["C"]); - } - - #[test] - fn parse_integration() { - let toml_src = r#" -[automaton] -name = "T" -states = ["A"] -initial = "A" - -[[integration]] -name = "payment" -trigger = "ChargeCard" -type = "wasm" -module = "payment_processor" -on_success = "PaymentConfirmed" -on_failure = "PaymentFailed" -"#; - let a: Automaton = toml::from_str(toml_src).unwrap(); - assert_eq!(a.integrations.len(), 1); - assert_eq!(a.integrations[0].name, "payment"); - assert_eq!(a.integrations[0].integration_type, "wasm"); - assert_eq!( - a.integrations[0].module.as_deref(), - Some("payment_processor") - ); - assert_eq!( - a.integrations[0].on_success.as_deref(), - Some("PaymentConfirmed") - ); - } - - #[test] - fn parse_webhook() { - let toml_src = r#" -[automaton] -name = "T" -states = ["A"] -initial = "A" - -[[webhook]] -name = "oauth_cb" -path = "oauth/callback" -action = "HandleCallback" -entity_param = "state" -"#; - let a: Automaton = toml::from_str(toml_src).unwrap(); - assert_eq!(a.webhooks.len(), 1); - assert_eq!(a.webhooks[0].name, "oauth_cb"); - assert_eq!(a.webhooks[0].method, "POST"); // default - assert_eq!(a.webhooks[0].entity_lookup, "query_param"); // default - } - - #[test] - fn parse_cross_entity_guard() { - let toml_src = r#" -[automaton] -name = "T" -states = ["A", "B"] -initial = "A" - -[[action]] -name = "Act" -from = ["A"] -to = "B" -guard = [{ type = "cross_entity_state", entity_type = "Parent", entity_id_source = "parent_id", required_status = ["Done", "Approved"] }] -"#; - let a: Automaton = toml::from_str(toml_src).unwrap(); - match &a.actions[0].guard[0] { - Guard::CrossEntityState { - entity_type, - entity_id_source, - required_status, - } => { - assert_eq!(entity_type, "Parent"); - assert_eq!(entity_id_source, "parent_id"); - assert_eq!( - required_status, - &vec!["Done".to_string(), "Approved".to_string()] - ); - } - other => panic!("expected CrossEntityState, got {other:?}"), - } - } - - #[test] - fn parse_context_entity() { - let toml_src = r#" -[automaton] -name = "T" -states = ["A"] -initial = "A" - -[[context_entity]] -name = "parent" -entity_type = "ParentEntity" -id_field = "parent_id" -"#; - let a: Automaton = toml::from_str(toml_src).unwrap(); - assert_eq!(a.context_entities.len(), 1); - assert_eq!(a.context_entities[0].name, "parent"); - assert_eq!(a.context_entities[0].entity_type, "ParentEntity"); - assert_eq!(a.context_entities[0].id_field, "parent_id"); - } - - #[test] - fn state_var_parsing() { - let toml_src = r#" -[automaton] -name = "T" -states = ["A"] -initial = "A" - -[[state]] -name = "count" -type = "counter" -initial = "0" - -[[state]] -name = "ready" -type = "bool" -initial = "false" -"#; - let a: Automaton = toml::from_str(toml_src).unwrap(); - assert_eq!(a.state.len(), 2); - assert_eq!(a.state[0].name, "count"); - assert_eq!(a.state[0].var_type, "counter"); - assert_eq!(a.state[1].var_type, "bool"); - assert_eq!(a.state[1].initial, "false"); - } -} +#[path = "types_test.rs"] +mod tests; diff --git a/crates/temper-spec/src/automaton/types_test.rs b/crates/temper-spec/src/automaton/types_test.rs new file mode 100644 index 00000000..720df640 --- /dev/null +++ b/crates/temper-spec/src/automaton/types_test.rs @@ -0,0 +1,299 @@ +use super::*; + +#[test] +fn parse_minimal_automaton() { + let toml_src = r#" +[automaton] +name = "Order" +states = ["Draft", "Active"] +initial = "Draft" +"#; + let automaton: Automaton = toml::from_str(toml_src).unwrap(); + assert_eq!(automaton.automaton.name, "Order"); + assert_eq!(automaton.automaton.states, vec!["Draft", "Active"]); + assert_eq!(automaton.automaton.initial, "Draft"); + assert!(automaton.actions.is_empty()); + assert!(automaton.invariants.is_empty()); + assert!(automaton.liveness.is_empty()); + assert!(automaton.integrations.is_empty()); +} + +#[test] +fn parse_action_defaults() { + let toml_src = r#" +[automaton] +name = "T" +states = ["A"] +initial = "A" + +[[action]] +name = "DoIt" +from = ["A"] +"#; + let automaton: Automaton = toml::from_str(toml_src).unwrap(); + assert_eq!(automaton.actions.len(), 1); + assert_eq!(automaton.actions[0].kind, "internal"); + assert!(automaton.actions[0].to.is_none()); + assert!(automaton.actions[0].guard.is_empty()); + assert!(automaton.actions[0].effect.is_empty()); +} + +#[test] +fn parse_guard_variants() { + let toml_src = r#" +[automaton] +name = "T" +states = ["A", "B"] +initial = "A" + +[[action]] +name = "G1" +from = ["A"] +to = "B" +guard = [ + { type = "min_count", var = "items", min = 1 }, + { type = "max_count", var = "items", max = 10 }, + { type = "is_true", var = "ready" }, + { type = "list_contains", var = "tags", value = "vip" }, + { type = "list_length_min", var = "tags", min = 2 }, +] +"#; + let automaton: Automaton = toml::from_str(toml_src).unwrap(); + let guards = &automaton.actions[0].guard; + assert_eq!(guards.len(), 5); + assert!(matches!(&guards[0], Guard::MinCount { var, min: 1 } if var == "items")); + assert!(matches!(&guards[1], Guard::MaxCount { var, max: 10 } if var == "items")); + assert!(matches!(&guards[2], Guard::IsTrue { var } if var == "ready")); + assert!( + matches!(&guards[3], Guard::ListContains { var, value } if var == "tags" && value == "vip") + ); + assert!(matches!(&guards[4], Guard::ListLengthMin { var, min: 2 } if var == "tags")); +} + +#[test] +fn parse_effect_variants() { + let toml_src = r#" +[automaton] +name = "T" +states = ["A"] +initial = "A" + +[[action]] +name = "E1" +from = ["A"] +effect = [ + { type = "increment", var = "count" }, + { type = "decrement", var = "count" }, + { type = "set_bool", var = "done", value = true }, + { type = "emit", event = "order_placed" }, + { type = "list_append", var = "log" }, + { type = "list_remove_at", var = "log" }, + { type = "trigger", name = "run_wasm" }, + { type = "schedule", action = "Retry", delay_seconds = 30 }, +] +"#; + let automaton: Automaton = toml::from_str(toml_src).unwrap(); + let effects = &automaton.actions[0].effect; + assert_eq!(effects.len(), 8); + assert!(matches!(&effects[0], Effect::Increment { var } if var == "count")); + assert!(matches!(&effects[1], Effect::Decrement { var } if var == "count")); + assert!(matches!(&effects[2], Effect::SetBool { var, value: true } if var == "done")); + assert!(matches!(&effects[3], Effect::Emit { event } if event == "order_placed")); + assert!(matches!(&effects[4], Effect::ListAppend { var } if var == "log")); + assert!(matches!(&effects[5], Effect::ListRemoveAt { var } if var == "log")); + assert!(matches!(&effects[6], Effect::Trigger { name } if name == "run_wasm")); + assert!( + matches!(&effects[7], Effect::Schedule { action, delay_seconds: 30 } if action == "Retry") + ); +} + +#[test] +fn parse_spawn_effect() { + let toml_src = r#" +[automaton] +name = "T" +states = ["A"] +initial = "A" + +[[action]] +name = "S1" +from = ["A"] +effect = [ + { type = "spawn", entity_type = "Child", entity_id_source = "{uuid}", initial_action = "Init", store_id_in = "child_id" }, +] +"#; + let automaton: Automaton = toml::from_str(toml_src).unwrap(); + match &automaton.actions[0].effect[0] { + Effect::Spawn { + entity_type, + entity_id_source, + initial_action, + store_id_in, + } => { + assert_eq!(entity_type, "Child"); + assert_eq!(entity_id_source, "{uuid}"); + assert_eq!(initial_action.as_deref(), Some("Init")); + assert_eq!(store_id_in.as_deref(), Some("child_id")); + } + other => panic!("expected Spawn, got {other:?}"), + } +} + +#[test] +fn parse_invariant_and_liveness() { + let toml_src = r#" +[automaton] +name = "T" +states = ["A", "B", "C"] +initial = "A" + +[[invariant]] +name = "NonNeg" +when = ["B"] +assert = "count >= 0" + +[[liveness]] +name = "Progress" +from = ["A"] +reaches = ["C"] +"#; + let automaton: Automaton = toml::from_str(toml_src).unwrap(); + assert_eq!(automaton.invariants.len(), 1); + assert_eq!(automaton.invariants[0].name, "NonNeg"); + assert_eq!(automaton.invariants[0].when, vec!["B"]); + assert_eq!(automaton.invariants[0].assert, "count >= 0"); + + assert_eq!(automaton.liveness.len(), 1); + assert_eq!(automaton.liveness[0].name, "Progress"); + assert_eq!(automaton.liveness[0].from, vec!["A"]); + assert_eq!(automaton.liveness[0].reaches, vec!["C"]); +} + +#[test] +fn parse_integration() { + let toml_src = r#" +[automaton] +name = "T" +states = ["A"] +initial = "A" + +[[integration]] +name = "payment" +trigger = "ChargeCard" +type = "wasm" +module = "payment_processor" +on_success = "PaymentConfirmed" +on_failure = "PaymentFailed" +"#; + let automaton: Automaton = toml::from_str(toml_src).unwrap(); + assert_eq!(automaton.integrations.len(), 1); + assert_eq!(automaton.integrations[0].name, "payment"); + assert_eq!(automaton.integrations[0].integration_type, "wasm"); + assert_eq!( + automaton.integrations[0].module.as_deref(), + Some("payment_processor") + ); + assert_eq!( + automaton.integrations[0].on_success.as_deref(), + Some("PaymentConfirmed") + ); +} + +#[test] +fn parse_webhook() { + let toml_src = r#" +[automaton] +name = "T" +states = ["A"] +initial = "A" + +[[webhook]] +name = "oauth_cb" +path = "oauth/callback" +action = "HandleCallback" +entity_param = "state" +"#; + let automaton: Automaton = toml::from_str(toml_src).unwrap(); + assert_eq!(automaton.webhooks.len(), 1); + assert_eq!(automaton.webhooks[0].name, "oauth_cb"); + assert_eq!(automaton.webhooks[0].method, "POST"); + assert_eq!(automaton.webhooks[0].entity_lookup, "query_param"); +} + +#[test] +fn parse_cross_entity_guard() { + let toml_src = r#" +[automaton] +name = "T" +states = ["A", "B"] +initial = "A" + +[[action]] +name = "Act" +from = ["A"] +to = "B" +guard = [{ type = "cross_entity_state", entity_type = "Parent", entity_id_source = "parent_id", required_status = ["Done", "Approved"] }] +"#; + let automaton: Automaton = toml::from_str(toml_src).unwrap(); + match &automaton.actions[0].guard[0] { + Guard::CrossEntityState { + entity_type, + entity_id_source, + required_status, + } => { + assert_eq!(entity_type, "Parent"); + assert_eq!(entity_id_source, "parent_id"); + assert_eq!( + required_status, + &vec!["Done".to_string(), "Approved".to_string()] + ); + } + other => panic!("expected CrossEntityState, got {other:?}"), + } +} + +#[test] +fn parse_context_entity() { + let toml_src = r#" +[automaton] +name = "T" +states = ["A"] +initial = "A" + +[[context_entity]] +name = "parent" +entity_type = "ParentEntity" +id_field = "parent_id" +"#; + let automaton: Automaton = toml::from_str(toml_src).unwrap(); + assert_eq!(automaton.context_entities.len(), 1); + assert_eq!(automaton.context_entities[0].name, "parent"); + assert_eq!(automaton.context_entities[0].entity_type, "ParentEntity"); + assert_eq!(automaton.context_entities[0].id_field, "parent_id"); +} + +#[test] +fn state_var_parsing() { + let toml_src = r#" +[automaton] +name = "T" +states = ["A"] +initial = "A" + +[[state]] +name = "count" +type = "counter" +initial = "0" + +[[state]] +name = "ready" +type = "bool" +initial = "false" +"#; + let automaton: Automaton = toml::from_str(toml_src).unwrap(); + assert_eq!(automaton.state.len(), 2); + assert_eq!(automaton.state[0].name, "count"); + assert_eq!(automaton.state[0].var_type, "counter"); + assert_eq!(automaton.state[1].var_type, "bool"); + assert_eq!(automaton.state[1].initial, "false"); +} diff --git a/crates/temper-spec/src/csdl/merge.rs b/crates/temper-spec/src/csdl/merge.rs index df7a9c03..1e03e888 100644 --- a/crates/temper-spec/src/csdl/merge.rs +++ b/crates/temper-spec/src/csdl/merge.rs @@ -1,6 +1,6 @@ //! Merge two [`CsdlDocument`]s by combining their schemas. -use super::types::CsdlDocument; +use super::types::{CsdlDocument, EntityContainer, Schema}; /// Merge two CSDL documents by combining their schemas. /// @@ -11,112 +11,102 @@ pub fn merge_csdl(existing: &CsdlDocument, incoming: &CsdlDocument) -> CsdlDocum let mut result = existing.clone(); for incoming_schema in &incoming.schemas { - if let Some(result_schema) = result - .schemas - .iter_mut() - .find(|s| s.namespace == incoming_schema.namespace) + merge_schema(&mut result.schemas, incoming_schema); + } + + result +} + +fn merge_schema(schemas: &mut Vec, incoming_schema: &Schema) { + let Some(result_schema) = schemas + .iter_mut() + .find(|schema| schema.namespace == incoming_schema.namespace) + else { + schemas.push(incoming_schema.clone()); + return; + }; + + merge_replace_by_name( + &mut result_schema.entity_types, + &incoming_schema.entity_types, + |item| item.name.as_str(), + ); + merge_replace_by_name( + &mut result_schema.enum_types, + &incoming_schema.enum_types, + |item| item.name.as_str(), + ); + merge_replace_by_name( + &mut result_schema.actions, + &incoming_schema.actions, + |item| item.name.as_str(), + ); + merge_replace_by_name( + &mut result_schema.functions, + &incoming_schema.functions, + |item| item.name.as_str(), + ); + + for container in &incoming_schema.entity_containers { + merge_entity_container(&mut result_schema.entity_containers, container); + } + + merge_append_missing_by_name(&mut result_schema.terms, &incoming_schema.terms, |item| { + item.name.as_str() + }); +} + +fn merge_entity_container(containers: &mut Vec, incoming: &EntityContainer) { + let Some(existing) = containers + .iter_mut() + .find(|container| container.name == incoming.name) + else { + containers.push(incoming.clone()); + return; + }; + + merge_replace_by_name(&mut existing.entity_sets, &incoming.entity_sets, |item| { + item.name.as_str() + }); + merge_append_missing_by_name( + &mut existing.action_imports, + &incoming.action_imports, + |item| item.name.as_str(), + ); + merge_append_missing_by_name( + &mut existing.function_imports, + &incoming.function_imports, + |item| item.name.as_str(), + ); +} + +fn merge_replace_by_name(target: &mut Vec, incoming: &[T], name: F) +where + T: Clone, + F: Fn(&T) -> &str + Copy, +{ + for item in incoming { + if let Some(position) = target + .iter() + .position(|existing| name(existing) == name(item)) { - // Merge entity types by name. - for et in &incoming_schema.entity_types { - if let Some(pos) = result_schema - .entity_types - .iter() - .position(|e| e.name == et.name) - { - result_schema.entity_types[pos] = et.clone(); - } else { - result_schema.entity_types.push(et.clone()); - } - } - // Merge enum types by name. - for et in &incoming_schema.enum_types { - if let Some(pos) = result_schema - .enum_types - .iter() - .position(|e| e.name == et.name) - { - result_schema.enum_types[pos] = et.clone(); - } else { - result_schema.enum_types.push(et.clone()); - } - } - // Merge actions by name. - for action in &incoming_schema.actions { - if let Some(pos) = result_schema - .actions - .iter() - .position(|a| a.name == action.name) - { - result_schema.actions[pos] = action.clone(); - } else { - result_schema.actions.push(action.clone()); - } - } - // Merge functions by name. - for func in &incoming_schema.functions { - if let Some(pos) = result_schema - .functions - .iter() - .position(|f| f.name == func.name) - { - result_schema.functions[pos] = func.clone(); - } else { - result_schema.functions.push(func.clone()); - } - } - // Merge entity containers by name, merging entity sets within. - for container in &incoming_schema.entity_containers { - if let Some(existing_container) = result_schema - .entity_containers - .iter_mut() - .find(|c| c.name == container.name) - { - for es in &container.entity_sets { - if let Some(pos) = existing_container - .entity_sets - .iter() - .position(|e| e.name == es.name) - { - existing_container.entity_sets[pos] = es.clone(); - } else { - existing_container.entity_sets.push(es.clone()); - } - } - for ai in &container.action_imports { - if !existing_container - .action_imports - .iter() - .any(|a| a.name == ai.name) - { - existing_container.action_imports.push(ai.clone()); - } - } - for fi in &container.function_imports { - if !existing_container - .function_imports - .iter() - .any(|f| f.name == fi.name) - { - existing_container.function_imports.push(fi.clone()); - } - } - } else { - result_schema.entity_containers.push(container.clone()); - } - } - // Merge terms by name. - for term in &incoming_schema.terms { - if !result_schema.terms.iter().any(|t| t.name == term.name) { - result_schema.terms.push(term.clone()); - } - } + target[position] = item.clone(); } else { - // New namespace — append entire schema. - result.schemas.push(incoming_schema.clone()); + target.push(item.clone()); } } +} - result +fn merge_append_missing_by_name(target: &mut Vec, incoming: &[T], name: F) +where + T: Clone, + F: Fn(&T) -> &str + Copy, +{ + for item in incoming { + if !target.iter().any(|existing| name(existing) == name(item)) { + target.push(item.clone()); + } + } } #[cfg(test)] diff --git a/crates/temper-spec/src/csdl/parser.rs b/crates/temper-spec/src/csdl/parser.rs deleted file mode 100644 index 979d8bb1..00000000 --- a/crates/temper-spec/src/csdl/parser.rs +++ /dev/null @@ -1,678 +0,0 @@ -use quick_xml::Reader; -use quick_xml::events::{BytesStart, Event}; - -use super::types::*; - -#[derive(Debug, thiserror::Error)] -pub enum CsdlParseError { - #[error("XML parse error: {0}")] - Xml(#[from] quick_xml::Error), - #[error("missing required attribute '{attr}' on element '{element}'")] - MissingAttribute { element: String, attr: String }, - #[error("unexpected element: {0}")] - UnexpectedElement(String), - #[error("invalid CSDL: {0}")] - Invalid(String), -} - -/// Parse a CSDL XML document from a string. -pub fn parse_csdl(xml: &str) -> Result { - let mut reader = Reader::from_str(xml); - let mut doc = CsdlDocument { - version: String::new(), - schemas: Vec::new(), - }; - - let mut buf = Vec::new(); - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => match local_name(e).as_str() { - "Edmx" => doc.version = attr_str(e, "Version").unwrap_or_default(), - "Schema" => doc.schemas.push(parse_schema(&mut reader, e)?), - _ => {} - }, - Ok(Event::Empty(ref e)) => { - if local_name(e) == "Edmx" { - doc.version = attr_str(e, "Version").unwrap_or_default(); - } - } - Ok(Event::Eof) => break, - Err(e) => return Err(CsdlParseError::Xml(e)), - _ => {} - } - buf.clear(); - } - Ok(doc) -} - -/// Read children of a element until . -fn parse_schema(reader: &mut Reader<&[u8]>, start: &BytesStart) -> Result { - let namespace = required_attr(start, "Namespace")?; - let mut schema = Schema { - namespace, - entity_types: Vec::new(), - enum_types: Vec::new(), - actions: Vec::new(), - functions: Vec::new(), - entity_containers: Vec::new(), - terms: Vec::new(), - }; - - let mut buf = Vec::new(); - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => match local_name(e).as_str() { - "EntityType" => schema.entity_types.push(parse_entity_type(reader, e)?), - "EnumType" => schema.enum_types.push(parse_enum_type(reader, e)?), - "Action" => schema.actions.push(parse_action(reader, e)?), - "Function" => schema.functions.push(parse_function(reader, e)?), - "EntityContainer" => schema - .entity_containers - .push(parse_entity_container(reader, e)?), - _ => { - skip_element(reader)?; - } - }, - Ok(Event::Empty(ref e)) => { - if local_name(e).as_str() == "Term" { - schema.terms.push(parse_term(e)); - } - } - Ok(Event::End(ref e)) if local_name_end(e) == "Schema" => break, - Ok(Event::Eof) => break, - Err(e) => return Err(CsdlParseError::Xml(e)), - _ => {} - } - buf.clear(); - } - Ok(schema) -} - -fn parse_entity_type( - reader: &mut Reader<&[u8]>, - start: &BytesStart, -) -> Result { - let name = required_attr(start, "Name")?; - let has_stream = attr_str(start, "HasStream").is_some_and(|v| v == "true"); - let mut et = EntityType { - name, - key_properties: Vec::new(), - properties: Vec::new(), - navigation_properties: Vec::new(), - annotations: Vec::new(), - has_stream, - }; - - let mut buf = Vec::new(); - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => { - match local_name(e).as_str() { - "NavigationProperty" => { - et.navigation_properties - .push(parse_navigation_property_children(reader, e)?); - } - "Annotation" => { - et.annotations.push(parse_annotation_children(reader, e)?); - } - "Key" => { - // Read Key's children (PropertyRef elements) - let mut kbuf = Vec::new(); - loop { - match reader.read_event_into(&mut kbuf) { - Ok(Event::Empty(ref ke)) if local_name(ke) == "PropertyRef" => { - if let Some(n) = attr_str(ke, "Name") { - et.key_properties.push(n); - } - } - Ok(Event::End(ref ke)) if local_name_end(ke) == "Key" => break, - Ok(Event::Eof) => break, - Err(e) => return Err(CsdlParseError::Xml(e)), - _ => {} - } - kbuf.clear(); - } - } - _ => { - skip_element(reader)?; - } - } - } - Ok(Event::Empty(ref e)) => match local_name(e).as_str() { - "PropertyRef" => { - if let Some(n) = attr_str(e, "Name") { - et.key_properties.push(n); - } - } - "Property" => et.properties.push(parse_property(e)), - "NavigationProperty" => { - et.navigation_properties.push(nav_prop_from_attrs(e)); - } - "Annotation" => { - if let Some(ann) = annotation_from_attrs(e) { - et.annotations.push(ann); - } - } - _ => {} - }, - Ok(Event::End(ref e)) if local_name_end(e) == "EntityType" => break, - Ok(Event::Eof) => break, - Err(e) => return Err(CsdlParseError::Xml(e)), - _ => {} - } - buf.clear(); - } - Ok(et) -} - -fn parse_enum_type( - reader: &mut Reader<&[u8]>, - start: &BytesStart, -) -> Result { - let name = required_attr(start, "Name")?; - let mut members = Vec::new(); - - let mut buf = Vec::new(); - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Empty(ref e)) if local_name(e) == "Member" => { - members.push(EnumMember { - name: attr_str(e, "Name").unwrap_or_default(), - value: attr_str(e, "Value").and_then(|v| v.parse().ok()), - }); - } - Ok(Event::End(ref e)) if local_name_end(e) == "EnumType" => break, - Ok(Event::Eof) => break, - Err(e) => return Err(CsdlParseError::Xml(e)), - _ => {} - } - buf.clear(); - } - Ok(EnumType { name, members }) -} - -fn parse_action(reader: &mut Reader<&[u8]>, start: &BytesStart) -> Result { - let name = required_attr(start, "Name")?; - let is_bound = attr_str(start, "IsBound").is_some_and(|v| v == "true"); - let mut parameters = Vec::new(); - let mut return_type = None; - let mut annotations = Vec::new(); - - let mut buf = Vec::new(); - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => match local_name(e).as_str() { - "Annotation" => annotations.push(parse_annotation_children(reader, e)?), - _ => { - skip_element(reader)?; - } - }, - Ok(Event::Empty(ref e)) => match local_name(e).as_str() { - "Parameter" => parameters.push(parse_parameter(e)), - "ReturnType" => return_type = Some(parse_return_type(e)), - "Annotation" => { - if let Some(ann) = annotation_from_attrs(e) { - annotations.push(ann); - } - } - _ => {} - }, - Ok(Event::End(ref e)) if local_name_end(e) == "Action" => break, - Ok(Event::Eof) => break, - Err(e) => return Err(CsdlParseError::Xml(e)), - _ => {} - } - buf.clear(); - } - Ok(Action { - name, - is_bound, - parameters, - return_type, - annotations, - }) -} - -fn parse_function( - reader: &mut Reader<&[u8]>, - start: &BytesStart, -) -> Result { - let name = required_attr(start, "Name")?; - let is_bound = attr_str(start, "IsBound").is_some_and(|v| v == "true"); - let mut parameters = Vec::new(); - let mut return_type = None; - let mut annotations = Vec::new(); - - let mut buf = Vec::new(); - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => match local_name(e).as_str() { - "Annotation" => annotations.push(parse_annotation_children(reader, e)?), - _ => { - skip_element(reader)?; - } - }, - Ok(Event::Empty(ref e)) => match local_name(e).as_str() { - "Parameter" => parameters.push(parse_parameter(e)), - "ReturnType" => return_type = Some(parse_return_type(e)), - "Annotation" => { - if let Some(ann) = annotation_from_attrs(e) { - annotations.push(ann); - } - } - _ => {} - }, - Ok(Event::End(ref e)) if local_name_end(e) == "Function" => break, - Ok(Event::Eof) => break, - Err(e) => return Err(CsdlParseError::Xml(e)), - _ => {} - } - buf.clear(); - } - Ok(Function { - name, - is_bound, - parameters, - return_type, - annotations, - }) -} - -fn parse_entity_container( - reader: &mut Reader<&[u8]>, - start: &BytesStart, -) -> Result { - let name = required_attr(start, "Name")?; - let mut entity_sets = Vec::new(); - let mut action_imports = Vec::new(); - let mut function_imports = Vec::new(); - - let mut buf = Vec::new(); - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => match local_name(e).as_str() { - "EntitySet" => entity_sets.push(parse_entity_set_children(reader, e)?), - _ => { - skip_element(reader)?; - } - }, - Ok(Event::Empty(ref e)) => match local_name(e).as_str() { - "EntitySet" => { - entity_sets.push(EntitySet { - name: attr_str(e, "Name").unwrap_or_default(), - entity_type: attr_str(e, "EntityType").unwrap_or_default(), - navigation_bindings: Vec::new(), - }); - } - "ActionImport" => { - action_imports.push(ActionImport { - name: attr_str(e, "Name").unwrap_or_default(), - action: attr_str(e, "Action").unwrap_or_default(), - }); - } - "FunctionImport" => { - function_imports.push(FunctionImport { - name: attr_str(e, "Name").unwrap_or_default(), - function: attr_str(e, "Function").unwrap_or_default(), - }); - } - _ => {} - }, - Ok(Event::End(ref e)) if local_name_end(e) == "EntityContainer" => break, - Ok(Event::Eof) => break, - Err(e) => return Err(CsdlParseError::Xml(e)), - _ => {} - } - buf.clear(); - } - Ok(EntityContainer { - name, - entity_sets, - action_imports, - function_imports, - }) -} - -// --- Element parsers for self-closing (Empty) elements --- - -fn parse_property(e: &BytesStart) -> Property { - Property { - name: attr_str(e, "Name").unwrap_or_default(), - type_name: attr_str(e, "Type").unwrap_or_else(|| "Edm.String".to_string()), - nullable: attr_str(e, "Nullable").is_none_or(|v| v != "false"), - default_value: attr_str(e, "DefaultValue"), - precision: attr_str(e, "Precision").and_then(|v| v.parse().ok()), - scale: attr_str(e, "Scale").and_then(|v| v.parse().ok()), - } -} - -fn parse_parameter(e: &BytesStart) -> Parameter { - Parameter { - name: attr_str(e, "Name").unwrap_or_default(), - type_name: attr_str(e, "Type").unwrap_or_else(|| "Edm.String".to_string()), - nullable: attr_str(e, "Nullable").is_none_or(|v| v != "false"), - default_value: attr_str(e, "DefaultValue"), - } -} - -fn parse_return_type(e: &BytesStart) -> ReturnType { - ReturnType { - type_name: attr_str(e, "Type").unwrap_or_default(), - nullable: attr_str(e, "Nullable").is_none_or(|v| v != "false"), - precision: attr_str(e, "Precision").and_then(|v| v.parse().ok()), - scale: attr_str(e, "Scale").and_then(|v| v.parse().ok()), - } -} - -fn parse_term(e: &BytesStart) -> Term { - Term { - name: attr_str(e, "Name").unwrap_or_default(), - type_name: attr_str(e, "Type").unwrap_or_default(), - applies_to: attr_str(e, "AppliesTo"), - description: attr_str(e, "Description"), - } -} - -fn nav_prop_from_attrs(e: &BytesStart) -> NavigationProperty { - NavigationProperty { - name: attr_str(e, "Name").unwrap_or_default(), - type_name: attr_str(e, "Type").unwrap_or_default(), - nullable: attr_str(e, "Nullable").is_none_or(|v| v != "false"), - contains_target: attr_str(e, "ContainsTarget").is_some_and(|v| v == "true"), - referential_constraints: Vec::new(), - } -} - -/// Parse annotation from inline attributes only (for self-closing elements). -fn annotation_from_attrs(e: &BytesStart) -> Option { - let term = attr_str(e, "Term")?; - - let value = if let Some(s) = attr_str(e, "String") { - AnnotationValue::String(s) - } else if let Some(f) = attr_str(e, "Float") { - AnnotationValue::Float(f.parse().unwrap_or(0.0)) - } else if let Some(b) = attr_str(e, "Bool") { - AnnotationValue::Bool(b == "true") - } else if let Some(i) = attr_str(e, "Int") { - AnnotationValue::Int(i.parse().unwrap_or(0)) - } else { - AnnotationValue::String(String::new()) - }; - - Some(Annotation { term, value }) -} - -// --- Element parsers for Start elements (have children) --- - -fn parse_navigation_property_children( - reader: &mut Reader<&[u8]>, - start: &BytesStart, -) -> Result { - let mut nav = nav_prop_from_attrs(start); - - let mut buf = Vec::new(); - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Empty(ref e)) if local_name(e) == "ReferentialConstraint" => { - nav.referential_constraints.push(ReferentialConstraint { - property: attr_str(e, "Property").unwrap_or_default(), - referenced_property: attr_str(e, "ReferencedProperty").unwrap_or_default(), - }); - } - Ok(Event::End(ref e)) if local_name_end(e) == "NavigationProperty" => break, - Ok(Event::Eof) => break, - Err(e) => return Err(CsdlParseError::Xml(e)), - _ => {} - } - buf.clear(); - } - Ok(nav) -} - -fn parse_entity_set_children( - reader: &mut Reader<&[u8]>, - start: &BytesStart, -) -> Result { - let name = attr_str(start, "Name").unwrap_or_default(); - let entity_type = attr_str(start, "EntityType").unwrap_or_default(); - let mut navigation_bindings = Vec::new(); - - let mut buf = Vec::new(); - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Empty(ref e)) if local_name(e) == "NavigationPropertyBinding" => { - navigation_bindings.push(NavigationBinding { - path: attr_str(e, "Path").unwrap_or_default(), - target: attr_str(e, "Target").unwrap_or_default(), - }); - } - Ok(Event::End(ref e)) if local_name_end(e) == "EntitySet" => break, - Ok(Event::Eof) => break, - Err(e) => return Err(CsdlParseError::Xml(e)), - _ => {} - } - buf.clear(); - } - Ok(EntitySet { - name, - entity_type, - navigation_bindings, - }) -} - -/// Parse annotation with nested children (Collection, String elements). -fn parse_annotation_children( - reader: &mut Reader<&[u8]>, - start: &BytesStart, -) -> Result { - let term = attr_str(start, "Term").unwrap_or_default(); - - // Check inline attributes first - if let Some(s) = attr_str(start, "String") { - // Has inline value but also has children (e.g., multi-line) — skip children - skip_element(reader)?; - return Ok(Annotation { - term, - value: AnnotationValue::String(s), - }); - } - if let Some(f) = attr_str(start, "Float") { - skip_element(reader)?; - return Ok(Annotation { - term, - value: AnnotationValue::Float(f.parse().unwrap_or(0.0)), - }); - } - - // Read children - let mut collection_items = Vec::new(); - let mut buf = Vec::new(); - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) if local_name(e) == "String" => { - let text = reader.read_text(e.name()).unwrap_or_default(); - let text = text.trim().to_string(); - if !text.is_empty() { - collection_items.push(text); - } - } - Ok(Event::End(ref e)) if local_name_end(e) == "Annotation" => break, - Ok(Event::Eof) => break, - Err(e) => return Err(CsdlParseError::Xml(e)), - _ => {} - } - buf.clear(); - } - - let value = if !collection_items.is_empty() { - AnnotationValue::Collection(collection_items) - } else { - AnnotationValue::String(String::new()) - }; - - Ok(Annotation { term, value }) -} - -// --- Utilities --- - -/// Skip past all children of the current element (consume until matching End). -fn skip_element(reader: &mut Reader<&[u8]>) -> Result<(), CsdlParseError> { - let mut depth: u32 = 1; - let mut buf = Vec::new(); - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(_)) => depth += 1, - Ok(Event::End(_)) => { - depth -= 1; - if depth == 0 { - break; - } - } - Ok(Event::Eof) => break, - Err(e) => return Err(CsdlParseError::Xml(e)), - _ => {} - } - buf.clear(); - } - Ok(()) -} - -fn local_name(e: &BytesStart) -> String { - let name = e.name(); - let full = std::str::from_utf8(name.as_ref()).unwrap_or(""); - full.rsplit(':').next().unwrap_or(full).to_string() -} - -fn local_name_end(e: &quick_xml::events::BytesEnd) -> String { - let name = e.name(); - let full = std::str::from_utf8(name.as_ref()).unwrap_or(""); - full.rsplit(':').next().unwrap_or(full).to_string() -} - -fn attr_str(e: &BytesStart, name: &str) -> Option { - e.attributes() - .flatten() - .find(|a| std::str::from_utf8(a.key.as_ref()).unwrap_or("") == name) - .and_then(|a| String::from_utf8(a.value.to_vec()).ok()) -} - -fn required_attr(e: &BytesStart, name: &str) -> Result { - attr_str(e, name).ok_or_else(|| CsdlParseError::MissingAttribute { - element: local_name(e), - attr: name.to_string(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_reference_example_csdl() { - let xml = include_str!("../../../../test-fixtures/specs/model.csdl.xml"); - let doc = parse_csdl(xml).expect("should parse without error"); - - assert_eq!(doc.version, "4.0"); - assert_eq!(doc.schemas.len(), 2); - - let schema = doc - .schemas - .iter() - .find(|s| s.namespace == "Temper.Example") - .expect("should have Temper.Example schema"); - - // Entity types - assert_eq!(schema.entity_types.len(), 7); - assert!(schema.entity_type("Customer").is_some()); - assert!(schema.entity_type("Order").is_some()); - assert!(schema.entity_type("Product").is_some()); - assert!(schema.entity_type("Payment").is_some()); - assert!(schema.entity_type("Shipment").is_some()); - assert!(schema.entity_type("OrderItem").is_some()); - assert!(schema.entity_type("Address").is_some()); - - // Enum types - assert_eq!(schema.enum_types.len(), 3); - - // Order details - let order = schema.entity_type("Order").unwrap(); - assert_eq!(order.key_properties, vec!["Id"]); - assert!(order.properties.len() > 10); - - let states = order.state_machine_states().expect("should have states"); - assert_eq!(states.len(), 10); - assert!(states.contains(&"Draft".to_string())); - assert!(states.contains(&"Shipped".to_string())); - assert_eq!(order.initial_state(), Some("Draft".to_string())); - assert_eq!(order.tla_spec_path(), Some("order.tla".to_string())); - - // Navigation properties - let customer_nav = order - .navigation_properties - .iter() - .find(|n| n.name == "Customer") - .unwrap(); - assert!(!customer_nav.contains_target); - assert_eq!(customer_nav.referential_constraints.len(), 1); - - let items_nav = order - .navigation_properties - .iter() - .find(|n| n.name == "Items") - .unwrap(); - assert!(items_nav.contains_target); - - // Actions - let submit = schema.action("SubmitOrder").unwrap(); - assert!(submit.is_bound); - assert_eq!(submit.valid_from_states(), Some(vec!["Draft".to_string()])); - assert_eq!(submit.target_state(), Some("Submitted".to_string())); - - let cancel = schema.action("CancelOrder").unwrap(); - let cancel_from = cancel.valid_from_states().unwrap(); - assert_eq!(cancel_from.len(), 3); - - // Functions - assert!(schema.function("GetOrderTotal").unwrap().is_bound); - assert!(!schema.function("SearchProducts").unwrap().is_bound); - - // Entity container - let container = &schema.entity_containers[0]; - assert_eq!(container.name, "ExampleService"); - assert_eq!(container.entity_sets.len(), 5); - - let orders_set = container - .entity_sets - .iter() - .find(|s| s.name == "Orders") - .unwrap(); - assert_eq!(orders_set.entity_type, "Temper.Example.Order"); - assert_eq!(orders_set.navigation_bindings.len(), 3); - } - - #[test] - fn test_parse_minimal_csdl() { - let xml = r#" - - - - - - - - - - - - - - "#; - - let doc = parse_csdl(xml).unwrap(); - let schema = &doc.schemas[0]; - assert_eq!(schema.namespace, "Test"); - let widget = schema.entity_type("Widget").unwrap(); - assert_eq!(widget.key_properties, vec!["Id"]); - assert_eq!(widget.properties.len(), 2); - } -} diff --git a/crates/temper-spec/src/csdl/parser/elements.rs b/crates/temper-spec/src/csdl/parser/elements.rs new file mode 100644 index 00000000..6e3f287e --- /dev/null +++ b/crates/temper-spec/src/csdl/parser/elements.rs @@ -0,0 +1,221 @@ +use quick_xml::Reader; +use quick_xml::events::BytesStart; + +use super::super::types::*; +use super::CsdlParseError; +use super::xml::{attr_str, local_name, local_name_end, skip_element}; + +pub(super) fn parse_property(element: &BytesStart) -> Property { + Property { + name: attr_str(element, "Name").unwrap_or_default(), + type_name: attr_str(element, "Type").unwrap_or_else(|| "Edm.String".to_string()), + nullable: attr_str(element, "Nullable").is_none_or(|v| v != "false"), + default_value: attr_str(element, "DefaultValue"), + precision: attr_str(element, "Precision").and_then(|v| v.parse().ok()), + scale: attr_str(element, "Scale").and_then(|v| v.parse().ok()), + } +} + +pub(super) fn parse_parameter(element: &BytesStart) -> Parameter { + Parameter { + name: attr_str(element, "Name").unwrap_or_default(), + type_name: attr_str(element, "Type").unwrap_or_else(|| "Edm.String".to_string()), + nullable: attr_str(element, "Nullable").is_none_or(|v| v != "false"), + default_value: attr_str(element, "DefaultValue"), + } +} + +pub(super) fn parse_return_type(element: &BytesStart) -> ReturnType { + ReturnType { + type_name: attr_str(element, "Type").unwrap_or_default(), + nullable: attr_str(element, "Nullable").is_none_or(|v| v != "false"), + precision: attr_str(element, "Precision").and_then(|v| v.parse().ok()), + scale: attr_str(element, "Scale").and_then(|v| v.parse().ok()), + } +} + +pub(super) fn parse_term(element: &BytesStart) -> Term { + Term { + name: attr_str(element, "Name").unwrap_or_default(), + type_name: attr_str(element, "Type").unwrap_or_default(), + applies_to: attr_str(element, "AppliesTo"), + description: attr_str(element, "Description"), + } +} + +pub(super) fn parse_entity_set_empty(element: &BytesStart) -> EntitySet { + EntitySet { + name: attr_str(element, "Name").unwrap_or_default(), + entity_type: attr_str(element, "EntityType").unwrap_or_default(), + navigation_bindings: Vec::new(), + } +} + +pub(super) fn parse_action_import(element: &BytesStart) -> ActionImport { + ActionImport { + name: attr_str(element, "Name").unwrap_or_default(), + action: attr_str(element, "Action").unwrap_or_default(), + } +} + +pub(super) fn parse_function_import(element: &BytesStart) -> FunctionImport { + FunctionImport { + name: attr_str(element, "Name").unwrap_or_default(), + function: attr_str(element, "Function").unwrap_or_default(), + } +} + +pub(super) fn nav_prop_from_attrs(element: &BytesStart) -> NavigationProperty { + NavigationProperty { + name: attr_str(element, "Name").unwrap_or_default(), + type_name: attr_str(element, "Type").unwrap_or_default(), + nullable: attr_str(element, "Nullable").is_none_or(|v| v != "false"), + contains_target: attr_str(element, "ContainsTarget").is_some_and(|v| v == "true"), + referential_constraints: Vec::new(), + } +} + +pub(super) fn annotation_from_attrs(element: &BytesStart) -> Option { + let term = attr_str(element, "Term")?; + let value = parse_inline_annotation_value(element); + Some(Annotation { term, value }) +} + +pub(super) fn parse_navigation_property_children( + reader: &mut Reader<&[u8]>, + start: &BytesStart, +) -> Result { + let mut navigation_property = nav_prop_from_attrs(start); + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(quick_xml::events::Event::Empty(ref element)) + if local_name(element) == "ReferentialConstraint" => + { + navigation_property + .referential_constraints + .push(ReferentialConstraint { + property: attr_str(element, "Property").unwrap_or_default(), + referenced_property: attr_str(element, "ReferencedProperty") + .unwrap_or_default(), + }); + } + Ok(quick_xml::events::Event::End(ref element)) + if local_name_end(element) == "NavigationProperty" => + { + break; + } + Ok(quick_xml::events::Event::Eof) => break, + Err(error) => return Err(CsdlParseError::Xml(error)), + _ => {} + } + buf.clear(); + } + + Ok(navigation_property) +} + +pub(super) fn parse_entity_set_children( + reader: &mut Reader<&[u8]>, + start: &BytesStart, +) -> Result { + let mut entity_set = parse_entity_set_empty(start); + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(quick_xml::events::Event::Empty(ref element)) + if local_name(element) == "NavigationPropertyBinding" => + { + entity_set.navigation_bindings.push(NavigationBinding { + path: attr_str(element, "Path").unwrap_or_default(), + target: attr_str(element, "Target").unwrap_or_default(), + }); + } + Ok(quick_xml::events::Event::End(ref element)) + if local_name_end(element) == "EntitySet" => + { + break; + } + Ok(quick_xml::events::Event::Eof) => break, + Err(error) => return Err(CsdlParseError::Xml(error)), + _ => {} + } + buf.clear(); + } + + Ok(entity_set) +} + +pub(super) fn parse_annotation_children( + reader: &mut Reader<&[u8]>, + start: &BytesStart, +) -> Result { + let term = attr_str(start, "Term").unwrap_or_default(); + + if let Some(value) = parse_inline_annotation_override(start) { + skip_element(reader)?; + return Ok(Annotation { term, value }); + } + + let mut collection_items = Vec::new(); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(quick_xml::events::Event::Start(ref element)) if local_name(element) == "String" => { + let text = reader.read_text(element.name()).unwrap_or_default(); + let text = text.trim().to_string(); + if !text.is_empty() { + collection_items.push(text); + } + } + Ok(quick_xml::events::Event::End(ref element)) + if local_name_end(element) == "Annotation" => + { + break; + } + Ok(quick_xml::events::Event::Eof) => break, + Err(error) => return Err(CsdlParseError::Xml(error)), + _ => {} + } + buf.clear(); + } + + let value = if collection_items.is_empty() { + AnnotationValue::String(String::new()) + } else { + AnnotationValue::Collection(collection_items) + }; + + Ok(Annotation { term, value }) +} + +fn parse_inline_annotation_override(element: &BytesStart) -> Option { + if let Some(string_value) = attr_str(element, "String") { + return Some(AnnotationValue::String(string_value)); + } + + if let Some(float_value) = attr_str(element, "Float") { + return Some(AnnotationValue::Float(float_value.parse().unwrap_or(0.0))); + } + + None +} + +fn parse_inline_annotation_value(element: &BytesStart) -> AnnotationValue { + if let Some(string_value) = attr_str(element, "String") { + return AnnotationValue::String(string_value); + } + if let Some(float_value) = attr_str(element, "Float") { + return AnnotationValue::Float(float_value.parse().unwrap_or(0.0)); + } + if let Some(bool_value) = attr_str(element, "Bool") { + return AnnotationValue::Bool(bool_value == "true"); + } + if let Some(int_value) = attr_str(element, "Int") { + return AnnotationValue::Int(int_value.parse().unwrap_or(0)); + } + + AnnotationValue::String(String::new()) +} diff --git a/crates/temper-spec/src/csdl/parser/mod.rs b/crates/temper-spec/src/csdl/parser/mod.rs new file mode 100644 index 00000000..37f67258 --- /dev/null +++ b/crates/temper-spec/src/csdl/parser/mod.rs @@ -0,0 +1,56 @@ +mod elements; +mod schema; +mod xml; + +use quick_xml::Reader; +use quick_xml::events::Event; + +use super::types::*; +use schema::parse_schema; +use xml::{attr_str, local_name}; + +#[derive(Debug, thiserror::Error)] +pub enum CsdlParseError { + #[error("XML parse error: {0}")] + Xml(#[from] quick_xml::Error), + #[error("missing required attribute '{attr}' on element '{element}'")] + MissingAttribute { element: String, attr: String }, + #[error("unexpected element: {0}")] + UnexpectedElement(String), + #[error("invalid CSDL: {0}")] + Invalid(String), +} + +/// Parse a CSDL XML document from a string. +pub fn parse_csdl(xml: &str) -> Result { + let mut reader = Reader::from_str(xml); + let mut doc = CsdlDocument { + version: String::new(), + schemas: Vec::new(), + }; + + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => match local_name(e).as_str() { + "Edmx" => doc.version = attr_str(e, "Version").unwrap_or_default(), + "Schema" => doc.schemas.push(parse_schema(&mut reader, e)?), + _ => {} + }, + Ok(Event::Empty(ref e)) => { + if local_name(e) == "Edmx" { + doc.version = attr_str(e, "Version").unwrap_or_default(); + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(CsdlParseError::Xml(e)), + _ => {} + } + buf.clear(); + } + Ok(doc) +} + +#[cfg(test)] +#[path = "test.rs"] +mod tests; diff --git a/crates/temper-spec/src/csdl/parser/schema.rs b/crates/temper-spec/src/csdl/parser/schema.rs new file mode 100644 index 00000000..25bb2108 --- /dev/null +++ b/crates/temper-spec/src/csdl/parser/schema.rs @@ -0,0 +1,281 @@ +use quick_xml::Reader; +use quick_xml::events::{BytesStart, Event}; + +use super::super::types::*; +use super::CsdlParseError; +use super::elements::{ + annotation_from_attrs, nav_prop_from_attrs, parse_action_import, parse_annotation_children, + parse_entity_set_children, parse_entity_set_empty, parse_function_import, + parse_navigation_property_children, parse_parameter, parse_property, parse_return_type, + parse_term, +}; +use super::xml::{attr_str, local_name, local_name_end, required_attr, skip_element}; + +pub(super) fn parse_schema( + reader: &mut Reader<&[u8]>, + start: &BytesStart, +) -> Result { + let namespace = required_attr(start, "Namespace")?; + let mut schema = Schema { + namespace, + entity_types: Vec::new(), + enum_types: Vec::new(), + actions: Vec::new(), + functions: Vec::new(), + entity_containers: Vec::new(), + terms: Vec::new(), + }; + + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref element)) => match local_name(element).as_str() { + "EntityType" => schema + .entity_types + .push(parse_entity_type(reader, element)?), + "EnumType" => schema.enum_types.push(parse_enum_type(reader, element)?), + "Action" => schema.actions.push(parse_action(reader, element)?), + "Function" => schema.functions.push(parse_function(reader, element)?), + "EntityContainer" => schema + .entity_containers + .push(parse_entity_container(reader, element)?), + _ => skip_element(reader)?, + }, + Ok(Event::Empty(ref element)) if local_name(element) == "Term" => { + schema.terms.push(parse_term(element)); + } + Ok(Event::End(ref element)) if local_name_end(element) == "Schema" => break, + Ok(Event::Eof) => break, + Err(error) => return Err(CsdlParseError::Xml(error)), + _ => {} + } + buf.clear(); + } + + Ok(schema) +} + +fn parse_entity_type( + reader: &mut Reader<&[u8]>, + start: &BytesStart, +) -> Result { + let mut entity_type = EntityType { + name: required_attr(start, "Name")?, + key_properties: Vec::new(), + properties: Vec::new(), + navigation_properties: Vec::new(), + annotations: Vec::new(), + has_stream: attr_str(start, "HasStream").is_some_and(|v| v == "true"), + }; + + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref element)) => match local_name(element).as_str() { + "NavigationProperty" => entity_type + .navigation_properties + .push(parse_navigation_property_children(reader, element)?), + "Annotation" => entity_type + .annotations + .push(parse_annotation_children(reader, element)?), + "Key" => parse_key(reader, &mut entity_type.key_properties)?, + _ => skip_element(reader)?, + }, + Ok(Event::Empty(ref element)) => match local_name(element).as_str() { + "PropertyRef" => push_property_ref(element, &mut entity_type.key_properties), + "Property" => entity_type.properties.push(parse_property(element)), + "NavigationProperty" => { + entity_type + .navigation_properties + .push(nav_prop_from_attrs(element)); + } + "Annotation" => { + if let Some(annotation) = annotation_from_attrs(element) { + entity_type.annotations.push(annotation); + } + } + _ => {} + }, + Ok(Event::End(ref element)) if local_name_end(element) == "EntityType" => break, + Ok(Event::Eof) => break, + Err(error) => return Err(CsdlParseError::Xml(error)), + _ => {} + } + buf.clear(); + } + + Ok(entity_type) +} + +fn parse_key( + reader: &mut Reader<&[u8]>, + key_properties: &mut Vec, +) -> Result<(), CsdlParseError> { + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Empty(ref element)) if local_name(element) == "PropertyRef" => { + push_property_ref(element, key_properties); + } + Ok(Event::End(ref element)) if local_name_end(element) == "Key" => break, + Ok(Event::Eof) => break, + Err(error) => return Err(CsdlParseError::Xml(error)), + _ => {} + } + buf.clear(); + } + + Ok(()) +} + +fn push_property_ref(element: &BytesStart, key_properties: &mut Vec) { + if let Some(name) = attr_str(element, "Name") { + key_properties.push(name); + } +} + +fn parse_enum_type( + reader: &mut Reader<&[u8]>, + start: &BytesStart, +) -> Result { + let mut members = Vec::new(); + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Empty(ref element)) if local_name(element) == "Member" => { + members.push(EnumMember { + name: attr_str(element, "Name").unwrap_or_default(), + value: attr_str(element, "Value").and_then(|v| v.parse().ok()), + }); + } + Ok(Event::End(ref element)) if local_name_end(element) == "EnumType" => break, + Ok(Event::Eof) => break, + Err(error) => return Err(CsdlParseError::Xml(error)), + _ => {} + } + buf.clear(); + } + + Ok(EnumType { + name: required_attr(start, "Name")?, + members, + }) +} + +fn parse_action(reader: &mut Reader<&[u8]>, start: &BytesStart) -> Result { + parse_operation( + reader, + start, + |name, is_bound, parameters, return_type, annotations| Action { + name, + is_bound, + parameters, + return_type, + annotations, + }, + ) +} + +fn parse_function( + reader: &mut Reader<&[u8]>, + start: &BytesStart, +) -> Result { + parse_operation( + reader, + start, + |name, is_bound, parameters, return_type, annotations| Function { + name, + is_bound, + parameters, + return_type, + annotations, + }, + ) +} + +fn parse_operation( + reader: &mut Reader<&[u8]>, + start: &BytesStart, + build: F, +) -> Result +where + F: FnOnce(String, bool, Vec, Option, Vec) -> T, +{ + let name = required_attr(start, "Name")?; + let is_bound = attr_str(start, "IsBound").is_some_and(|v| v == "true"); + let mut parameters = Vec::new(); + let mut return_type = None; + let mut annotations = Vec::new(); + let end_name = local_name(start); + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref element)) => match local_name(element).as_str() { + "Annotation" => annotations.push(parse_annotation_children(reader, element)?), + _ => skip_element(reader)?, + }, + Ok(Event::Empty(ref element)) => match local_name(element).as_str() { + "Parameter" => parameters.push(parse_parameter(element)), + "ReturnType" => return_type = Some(parse_return_type(element)), + "Annotation" => { + if let Some(annotation) = annotation_from_attrs(element) { + annotations.push(annotation); + } + } + _ => {} + }, + Ok(Event::End(ref element)) if local_name_end(element) == end_name => break, + Ok(Event::Eof) => break, + Err(error) => return Err(CsdlParseError::Xml(error)), + _ => {} + } + buf.clear(); + } + + Ok(build(name, is_bound, parameters, return_type, annotations)) +} + +fn parse_entity_container( + reader: &mut Reader<&[u8]>, + start: &BytesStart, +) -> Result { + let mut entity_container = EntityContainer { + name: required_attr(start, "Name")?, + entity_sets: Vec::new(), + action_imports: Vec::new(), + function_imports: Vec::new(), + }; + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref element)) => match local_name(element).as_str() { + "EntitySet" => entity_container + .entity_sets + .push(parse_entity_set_children(reader, element)?), + _ => skip_element(reader)?, + }, + Ok(Event::Empty(ref element)) => match local_name(element).as_str() { + "EntitySet" => entity_container + .entity_sets + .push(parse_entity_set_empty(element)), + "ActionImport" => entity_container + .action_imports + .push(parse_action_import(element)), + "FunctionImport" => entity_container + .function_imports + .push(parse_function_import(element)), + _ => {} + }, + Ok(Event::End(ref element)) if local_name_end(element) == "EntityContainer" => break, + Ok(Event::Eof) => break, + Err(error) => return Err(CsdlParseError::Xml(error)), + _ => {} + } + buf.clear(); + } + + Ok(entity_container) +} diff --git a/crates/temper-spec/src/csdl/parser/test.rs b/crates/temper-spec/src/csdl/parser/test.rs new file mode 100644 index 00000000..6e83da24 --- /dev/null +++ b/crates/temper-spec/src/csdl/parser/test.rs @@ -0,0 +1,116 @@ +use super::*; + +#[test] +fn test_parse_reference_example_csdl() { + let xml = include_str!("../../../../../test-fixtures/specs/model.csdl.xml"); + let doc = parse_csdl(xml).expect("should parse without error"); + let schema = example_schema(&doc); + + assert_eq!(doc.version, "4.0"); + assert_eq!(doc.schemas.len(), 2); + assert_example_entities(schema); + assert_example_order(schema); + assert_example_operations(schema); + assert_example_container(schema); +} + +#[test] +fn test_parse_minimal_csdl() { + let xml = r#" + + + + + + + + + + + + + + "#; + + let doc = parse_csdl(xml).unwrap(); + let schema = &doc.schemas[0]; + assert_eq!(schema.namespace, "Test"); + let widget = schema.entity_type("Widget").unwrap(); + assert_eq!(widget.key_properties, vec!["Id"]); + assert_eq!(widget.properties.len(), 2); +} + +fn example_schema(doc: &CsdlDocument) -> &Schema { + doc.schemas + .iter() + .find(|schema| schema.namespace == "Temper.Example") + .expect("should have Temper.Example schema") +} + +fn assert_example_entities(schema: &Schema) { + assert_eq!(schema.entity_types.len(), 7); + assert!(schema.entity_type("Customer").is_some()); + assert!(schema.entity_type("Order").is_some()); + assert!(schema.entity_type("Product").is_some()); + assert!(schema.entity_type("Payment").is_some()); + assert!(schema.entity_type("Shipment").is_some()); + assert!(schema.entity_type("OrderItem").is_some()); + assert!(schema.entity_type("Address").is_some()); + assert_eq!(schema.enum_types.len(), 3); +} + +fn assert_example_order(schema: &Schema) { + let order = schema.entity_type("Order").unwrap(); + assert_eq!(order.key_properties, vec!["Id"]); + assert!(order.properties.len() > 10); + + let states = order.state_machine_states().expect("should have states"); + assert_eq!(states.len(), 10); + assert!(states.contains(&"Draft".to_string())); + assert!(states.contains(&"Shipped".to_string())); + assert_eq!(order.initial_state(), Some("Draft".to_string())); + assert_eq!(order.tla_spec_path(), Some("order.tla".to_string())); + + let customer_nav = order + .navigation_properties + .iter() + .find(|navigation| navigation.name == "Customer") + .unwrap(); + assert!(!customer_nav.contains_target); + assert_eq!(customer_nav.referential_constraints.len(), 1); + + let items_nav = order + .navigation_properties + .iter() + .find(|navigation| navigation.name == "Items") + .unwrap(); + assert!(items_nav.contains_target); +} + +fn assert_example_operations(schema: &Schema) { + let submit = schema.action("SubmitOrder").unwrap(); + assert!(submit.is_bound); + assert_eq!(submit.valid_from_states(), Some(vec!["Draft".to_string()])); + assert_eq!(submit.target_state(), Some("Submitted".to_string())); + + let cancel = schema.action("CancelOrder").unwrap(); + let cancel_from = cancel.valid_from_states().unwrap(); + assert_eq!(cancel_from.len(), 3); + + assert!(schema.function("GetOrderTotal").unwrap().is_bound); + assert!(!schema.function("SearchProducts").unwrap().is_bound); +} + +fn assert_example_container(schema: &Schema) { + let container = &schema.entity_containers[0]; + assert_eq!(container.name, "ExampleService"); + assert_eq!(container.entity_sets.len(), 5); + + let orders_set = container + .entity_sets + .iter() + .find(|entity_set| entity_set.name == "Orders") + .unwrap(); + assert_eq!(orders_set.entity_type, "Temper.Example.Order"); + assert_eq!(orders_set.navigation_bindings.len(), 3); +} diff --git a/crates/temper-spec/src/csdl/parser/xml.rs b/crates/temper-spec/src/csdl/parser/xml.rs new file mode 100644 index 00000000..b8dc630f --- /dev/null +++ b/crates/temper-spec/src/csdl/parser/xml.rs @@ -0,0 +1,54 @@ +use quick_xml::Reader; +use quick_xml::events::{BytesEnd, BytesStart}; + +use super::CsdlParseError; + +pub(super) fn skip_element(reader: &mut Reader<&[u8]>) -> Result<(), CsdlParseError> { + let mut depth: u32 = 1; + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(quick_xml::events::Event::Start(_)) => depth += 1, + Ok(quick_xml::events::Event::End(_)) => { + depth -= 1; + if depth == 0 { + break; + } + } + Ok(quick_xml::events::Event::Eof) => break, + Err(error) => return Err(CsdlParseError::Xml(error)), + _ => {} + } + buf.clear(); + } + + Ok(()) +} + +pub(super) fn local_name(element: &BytesStart) -> String { + let name = element.name(); + let full = std::str::from_utf8(name.as_ref()).unwrap_or(""); + full.rsplit(':').next().unwrap_or(full).to_string() +} + +pub(super) fn local_name_end(element: &BytesEnd) -> String { + let name = element.name(); + let full = std::str::from_utf8(name.as_ref()).unwrap_or(""); + full.rsplit(':').next().unwrap_or(full).to_string() +} + +pub(super) fn attr_str(element: &BytesStart, name: &str) -> Option { + element + .attributes() + .flatten() + .find(|attribute| std::str::from_utf8(attribute.key.as_ref()).unwrap_or("") == name) + .and_then(|attribute| String::from_utf8(attribute.value.to_vec()).ok()) +} + +pub(super) fn required_attr(element: &BytesStart, name: &str) -> Result { + attr_str(element, name).ok_or_else(|| CsdlParseError::MissingAttribute { + element: local_name(element), + attr: name.to_string(), + }) +} diff --git a/crates/temper-spec/src/csdl/types.rs b/crates/temper-spec/src/csdl/types.rs index 45aedd51..5f8d7c5e 100644 --- a/crates/temper-spec/src/csdl/types.rs +++ b/crates/temper-spec/src/csdl/types.rs @@ -273,222 +273,5 @@ impl Action { } #[cfg(test)] -mod tests { - use super::*; - - fn sample_schema() -> Schema { - Schema { - namespace: "TestNs".into(), - entity_types: vec![ - EntityType { - name: "Order".into(), - key_properties: vec!["Id".into()], - properties: vec![], - navigation_properties: vec![], - annotations: vec![ - Annotation { - term: "StateMachine.States".into(), - value: AnnotationValue::Collection(vec![ - "Draft".into(), - "Active".into(), - ]), - }, - Annotation { - term: "StateMachine.InitialState".into(), - value: AnnotationValue::String("Draft".into()), - }, - ], - has_stream: false, - }, - EntityType { - name: "Customer".into(), - key_properties: vec!["Id".into()], - properties: vec![], - navigation_properties: vec![], - annotations: vec![], - has_stream: false, - }, - ], - enum_types: vec![EnumType { - name: "Status".into(), - members: vec![ - EnumMember { - name: "Active".into(), - value: Some(0), - }, - EnumMember { - name: "Inactive".into(), - value: Some(1), - }, - ], - }], - actions: vec![Action { - name: "Submit".into(), - is_bound: true, - parameters: vec![Parameter { - name: "bindingParameter".into(), - type_name: "TestNs.Order".into(), - nullable: false, - default_value: None, - }], - return_type: None, - annotations: vec![ - Annotation { - term: "StateMachine.ValidFromStates".into(), - value: AnnotationValue::Collection(vec!["Draft".into()]), - }, - Annotation { - term: "StateMachine.TargetState".into(), - value: AnnotationValue::String("Active".into()), - }, - ], - }], - functions: vec![Function { - name: "GetTotal".into(), - is_bound: false, - parameters: vec![], - return_type: Some(ReturnType { - type_name: "Edm.Decimal".into(), - nullable: false, - precision: Some(10), - scale: Some(2), - }), - annotations: vec![], - }], - entity_containers: vec![], - terms: vec![], - } - } - - #[test] - fn schema_find_entity_type() { - let schema = sample_schema(); - assert!(schema.entity_type("Order").is_some()); - assert!(schema.entity_type("Customer").is_some()); - assert!(schema.entity_type("Missing").is_none()); - } - - #[test] - fn schema_find_action() { - let schema = sample_schema(); - assert!(schema.action("Submit").is_some()); - assert!(schema.action("Missing").is_none()); - } - - #[test] - fn schema_find_function() { - let schema = sample_schema(); - assert!(schema.function("GetTotal").is_some()); - assert!(schema.function("Missing").is_none()); - } - - #[test] - fn schema_find_enum_type() { - let schema = sample_schema(); - assert!(schema.enum_type("Status").is_some()); - assert!(schema.enum_type("Missing").is_none()); - } - - #[test] - fn entity_type_state_machine_states() { - let schema = sample_schema(); - let order = schema.entity_type("Order").unwrap(); - let states = order.state_machine_states().unwrap(); - assert_eq!(states, vec!["Draft", "Active"]); - } - - #[test] - fn entity_type_initial_state() { - let schema = sample_schema(); - let order = schema.entity_type("Order").unwrap(); - assert_eq!(order.initial_state().unwrap(), "Draft"); - } - - #[test] - fn entity_type_no_annotations() { - let schema = sample_schema(); - let customer = schema.entity_type("Customer").unwrap(); - assert!(customer.state_machine_states().is_none()); - assert!(customer.initial_state().is_none()); - assert!(customer.tla_spec_path().is_none()); - } - - #[test] - fn entity_type_annotation_short_name_match() { - let schema = sample_schema(); - let order = schema.entity_type("Order").unwrap(); - // annotation() matches both exact and suffix ".{term}" - assert!(order.annotation("StateMachine.States").is_some()); - assert!(order.annotation("States").is_some()); - assert!(order.annotation("NonExistent").is_none()); - } - - #[test] - fn action_valid_from_states() { - let schema = sample_schema(); - let submit = schema.action("Submit").unwrap(); - assert_eq!(submit.valid_from_states().unwrap(), vec!["Draft"]); - } - - #[test] - fn action_target_state() { - let schema = sample_schema(); - let submit = schema.action("Submit").unwrap(); - assert_eq!(submit.target_state().unwrap(), "Active"); - } - - #[test] - fn action_binding_type_bound() { - let schema = sample_schema(); - let submit = schema.action("Submit").unwrap(); - assert_eq!(submit.binding_type(), Some("TestNs.Order")); - } - - #[test] - fn action_binding_type_unbound() { - let action = Action { - name: "Unbound".into(), - is_bound: false, - parameters: vec![], - return_type: None, - annotations: vec![], - }; - assert!(action.binding_type().is_none()); - } - - #[test] - fn csdl_document_schemas_by_namespace() { - let doc = CsdlDocument { - version: "4.0".into(), - schemas: vec![ - sample_schema(), - Schema { - namespace: "OtherNs".into(), - entity_types: vec![], - enum_types: vec![], - actions: vec![], - functions: vec![], - entity_containers: vec![], - terms: vec![], - }, - ], - }; - assert_eq!(doc.schemas_by_namespace("Test").len(), 1); - assert_eq!(doc.schemas_by_namespace("Other").len(), 1); - assert_eq!(doc.schemas_by_namespace("None").len(), 0); - } - - #[test] - fn annotation_value_variants() { - let s = AnnotationValue::String("hello".into()); - let f = AnnotationValue::Float(2.72); - let b = AnnotationValue::Bool(true); - let i = AnnotationValue::Int(42); - let c = AnnotationValue::Collection(vec!["a".into()]); - let r = AnnotationValue::Record(HashMap::from([("k".into(), "v".into())])); - // Verify Debug works (ensures derive is correct) - for v in [&s, &f, &b, &i, &c, &r] { - assert!(!format!("{v:?}").is_empty()); - } - } -} +#[path = "types_test.rs"] +mod tests; diff --git a/crates/temper-spec/src/csdl/types_test.rs b/crates/temper-spec/src/csdl/types_test.rs new file mode 100644 index 00000000..0ddf7304 --- /dev/null +++ b/crates/temper-spec/src/csdl/types_test.rs @@ -0,0 +1,228 @@ +use super::*; + +fn sample_schema() -> Schema { + Schema { + namespace: "TestNs".into(), + entity_types: sample_entity_types(), + enum_types: sample_enum_types(), + actions: sample_actions(), + functions: sample_functions(), + entity_containers: vec![], + terms: vec![], + } +} + +fn sample_entity_types() -> Vec { + vec![ + EntityType { + name: "Order".into(), + key_properties: vec!["Id".into()], + properties: vec![], + navigation_properties: vec![], + annotations: vec![ + Annotation { + term: "StateMachine.States".into(), + value: AnnotationValue::Collection(vec!["Draft".into(), "Active".into()]), + }, + Annotation { + term: "StateMachine.InitialState".into(), + value: AnnotationValue::String("Draft".into()), + }, + ], + has_stream: false, + }, + EntityType { + name: "Customer".into(), + key_properties: vec!["Id".into()], + properties: vec![], + navigation_properties: vec![], + annotations: vec![], + has_stream: false, + }, + ] +} + +fn sample_enum_types() -> Vec { + vec![EnumType { + name: "Status".into(), + members: vec![ + EnumMember { + name: "Active".into(), + value: Some(0), + }, + EnumMember { + name: "Inactive".into(), + value: Some(1), + }, + ], + }] +} + +fn sample_actions() -> Vec { + vec![Action { + name: "Submit".into(), + is_bound: true, + parameters: vec![Parameter { + name: "bindingParameter".into(), + type_name: "TestNs.Order".into(), + nullable: false, + default_value: None, + }], + return_type: None, + annotations: vec![ + Annotation { + term: "StateMachine.ValidFromStates".into(), + value: AnnotationValue::Collection(vec!["Draft".into()]), + }, + Annotation { + term: "StateMachine.TargetState".into(), + value: AnnotationValue::String("Active".into()), + }, + ], + }] +} + +fn sample_functions() -> Vec { + vec![Function { + name: "GetTotal".into(), + is_bound: false, + parameters: vec![], + return_type: Some(ReturnType { + type_name: "Edm.Decimal".into(), + nullable: false, + precision: Some(10), + scale: Some(2), + }), + annotations: vec![], + }] +} + +#[test] +fn schema_find_entity_type() { + let schema = sample_schema(); + assert!(schema.entity_type("Order").is_some()); + assert!(schema.entity_type("Customer").is_some()); + assert!(schema.entity_type("Missing").is_none()); +} + +#[test] +fn schema_find_action() { + let schema = sample_schema(); + assert!(schema.action("Submit").is_some()); + assert!(schema.action("Missing").is_none()); +} + +#[test] +fn schema_find_function() { + let schema = sample_schema(); + assert!(schema.function("GetTotal").is_some()); + assert!(schema.function("Missing").is_none()); +} + +#[test] +fn schema_find_enum_type() { + let schema = sample_schema(); + assert!(schema.enum_type("Status").is_some()); + assert!(schema.enum_type("Missing").is_none()); +} + +#[test] +fn entity_type_state_machine_states() { + let schema = sample_schema(); + let order = schema.entity_type("Order").unwrap(); + let states = order.state_machine_states().unwrap(); + assert_eq!(states, vec!["Draft", "Active"]); +} + +#[test] +fn entity_type_initial_state() { + let schema = sample_schema(); + let order = schema.entity_type("Order").unwrap(); + assert_eq!(order.initial_state().unwrap(), "Draft"); +} + +#[test] +fn entity_type_no_annotations() { + let schema = sample_schema(); + let customer = schema.entity_type("Customer").unwrap(); + assert!(customer.state_machine_states().is_none()); + assert!(customer.initial_state().is_none()); + assert!(customer.tla_spec_path().is_none()); +} + +#[test] +fn entity_type_annotation_short_name_match() { + let schema = sample_schema(); + let order = schema.entity_type("Order").unwrap(); + assert!(order.annotation("StateMachine.States").is_some()); + assert!(order.annotation("States").is_some()); + assert!(order.annotation("NonExistent").is_none()); +} + +#[test] +fn action_valid_from_states() { + let schema = sample_schema(); + let submit = schema.action("Submit").unwrap(); + assert_eq!(submit.valid_from_states().unwrap(), vec!["Draft"]); +} + +#[test] +fn action_target_state() { + let schema = sample_schema(); + let submit = schema.action("Submit").unwrap(); + assert_eq!(submit.target_state().unwrap(), "Active"); +} + +#[test] +fn action_binding_type_bound() { + let schema = sample_schema(); + let submit = schema.action("Submit").unwrap(); + assert_eq!(submit.binding_type(), Some("TestNs.Order")); +} + +#[test] +fn action_binding_type_unbound() { + let action = Action { + name: "Unbound".into(), + is_bound: false, + parameters: vec![], + return_type: None, + annotations: vec![], + }; + assert!(action.binding_type().is_none()); +} + +#[test] +fn csdl_document_schemas_by_namespace() { + let doc = CsdlDocument { + version: "4.0".into(), + schemas: vec![ + sample_schema(), + Schema { + namespace: "OtherNs".into(), + entity_types: vec![], + enum_types: vec![], + actions: vec![], + functions: vec![], + entity_containers: vec![], + terms: vec![], + }, + ], + }; + assert_eq!(doc.schemas_by_namespace("Test").len(), 1); + assert_eq!(doc.schemas_by_namespace("Other").len(), 1); + assert_eq!(doc.schemas_by_namespace("None").len(), 0); +} + +#[test] +fn annotation_value_variants() { + let s = AnnotationValue::String("hello".into()); + let f = AnnotationValue::Float(2.72); + let b = AnnotationValue::Bool(true); + let i = AnnotationValue::Int(42); + let c = AnnotationValue::Collection(vec!["a".into()]); + let r = AnnotationValue::Record(HashMap::from([("k".into(), "v".into())])); + for value in [&s, &f, &b, &i, &c, &r] { + assert!(!format!("{value:?}").is_empty()); + } +} diff --git a/crates/temper-spec/src/model/mod.rs b/crates/temper-spec/src/model/mod.rs index cf977766..5a5b7a10 100644 --- a/crates/temper-spec/src/model/mod.rs +++ b/crates/temper-spec/src/model/mod.rs @@ -28,11 +28,14 @@ pub struct SpecModel { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ValidationResult { + /// Validation failures that should block downstream codegen or linking. pub errors: Vec, + /// Non-blocking mismatches or gaps detected during spec linking. pub warnings: Vec, } impl ValidationResult { + /// Returns true when the linked specification contains no validation errors. pub fn is_valid(&self) -> bool { self.errors.is_empty() } @@ -63,92 +66,140 @@ pub fn build_spec_model_mixed( csdl: csdl::CsdlDocument, sources: HashMap, ) -> SpecModel { - let mut state_machines = HashMap::new(); let mut validation = ValidationResult::default(); + let state_machines = parse_state_machines(&sources, &mut validation); + validate_csdl_links(&csdl, &state_machines, &mut validation); - // Parse each specification source - for (entity_name, source) in &sources { - match source { - SpecSource::Tla(tla_text) => match tlaplus::extract_state_machine(tla_text) { - Ok(sm) => { - state_machines.insert(entity_name.clone(), sm); - } - Err(e) => { - validation.errors.push(format!( - "Failed to extract state machine for {entity_name} (TLA+): {e}" - )); - } - }, - SpecSource::Ioa(ioa_text) => match automaton::parse_automaton(ioa_text) { - Ok(aut) => { - let sm = automaton::to_state_machine(&aut); - state_machines.insert(entity_name.clone(), sm); - } - Err(e) => { - validation.errors.push(format!( - "Failed to parse IOA automaton for {entity_name}: {e}" - )); - } - }, + SpecModel { + csdl, + state_machines, + validation, + } +} + +fn parse_state_machines( + sources: &HashMap, + validation: &mut ValidationResult, +) -> HashMap { + let mut state_machines = HashMap::new(); + + for (entity_name, source) in sources { + match parse_source_state_machine(entity_name, source) { + Ok(state_machine) => { + state_machines.insert(entity_name.clone(), state_machine); + } + Err(message) => validation.errors.push(message), } } - // Cross-validate CSDL annotations against specification state machines + state_machines +} + +fn parse_source_state_machine( + entity_name: &str, + source: &SpecSource, +) -> Result { + match source { + SpecSource::Tla(tla_text) => tlaplus::extract_state_machine(tla_text).map_err(|error| { + format!("Failed to extract state machine for {entity_name} (TLA+): {error}") + }), + SpecSource::Ioa(ioa_text) => automaton::parse_automaton(ioa_text) + .map(|automaton| automaton::to_state_machine(&automaton)) + .map_err(|error| format!("Failed to parse IOA automaton for {entity_name}: {error}")), + } +} + +fn validate_csdl_links( + csdl: &csdl::CsdlDocument, + state_machines: &HashMap, + validation: &mut ValidationResult, +) { for schema in &csdl.schemas { - for entity_type in &schema.entity_types { - if let Some(csdl_states) = entity_type.state_machine_states() { - if let Some(sm) = state_machines.get(&entity_type.name) { - // Verify all CSDL-declared states exist in spec - for state in &csdl_states { - if !sm.states.contains(state) { - validation.errors.push(format!( - "{}: CSDL declares state '{}' but specification does not contain it", - entity_type.name, state - )); - } - } - // Verify all spec states are in CSDL - for state in &sm.states { - if !csdl_states.contains(state) { - validation.warnings.push(format!( - "{}: specification has state '{}' not declared in CSDL annotations", - entity_type.name, state - )); - } - } - } else if entity_type.tla_spec_path().is_some() { - validation.warnings.push(format!( - "{}: has TlaSpec annotation but no specification source was provided", - entity_type.name - )); - } - } + validate_entity_states(schema, state_machines, validation); + validate_action_bindings(schema, state_machines, validation); + } +} + +fn validate_entity_states( + schema: &csdl::Schema, + state_machines: &HashMap, + validation: &mut ValidationResult, +) { + for entity_type in &schema.entity_types { + let Some(csdl_states) = entity_type.state_machine_states() else { + continue; + }; + + if let Some(state_machine) = state_machines.get(&entity_type.name) { + record_missing_csdl_states(entity_type, &csdl_states, state_machine, validation); + record_missing_spec_states(entity_type, &csdl_states, state_machine, validation); + } else if entity_type.tla_spec_path().is_some() { + validation.warnings.push(format!( + "{}: has TlaSpec annotation but no specification source was provided", + entity_type.name + )); } + } +} - // Validate action valid-from states against specification transitions - for action in &schema.actions { - if let Some(from_states) = action.valid_from_states() - && let Some(binding_type) = action.binding_type() - { - let entity_name = binding_type.rsplit('.').next().unwrap_or(binding_type); - if let Some(sm) = state_machines.get(entity_name) { - for state in &from_states { - if !sm.states.contains(state) { - validation.errors.push(format!( - "Action {}: ValidFromStates contains '{}' which is not in {}'s specification states", - action.name, state, entity_name - )); - } - } - } - } +fn record_missing_csdl_states( + entity_type: &csdl::EntityType, + csdl_states: &[String], + state_machine: &tlaplus::StateMachine, + validation: &mut ValidationResult, +) { + for state in csdl_states { + if !state_machine.states.contains(state) { + validation.errors.push(format!( + "{}: CSDL declares state '{}' but specification does not contain it", + entity_type.name, state + )); } } +} - SpecModel { - csdl, - state_machines, - validation, +fn record_missing_spec_states( + entity_type: &csdl::EntityType, + csdl_states: &[String], + state_machine: &tlaplus::StateMachine, + validation: &mut ValidationResult, +) { + for state in &state_machine.states { + if !csdl_states.contains(state) { + validation.warnings.push(format!( + "{}: specification has state '{}' not declared in CSDL annotations", + entity_type.name, state + )); + } + } +} + +fn validate_action_bindings( + schema: &csdl::Schema, + state_machines: &HashMap, + validation: &mut ValidationResult, +) { + for action in &schema.actions { + let Some(from_states) = action.valid_from_states() else { + continue; + }; + let Some(binding_type) = action.binding_type() else { + continue; + }; + + let entity_name = binding_type.rsplit('.').next().unwrap_or(binding_type); + let Some(state_machine) = state_machines.get(entity_name) else { + continue; + }; + + for state in &from_states { + if !state_machine.states.contains(state) { + validation.errors.push(format!( + "Action {}: ValidFromStates contains '{}' which is not in {}'s specification states", + action.name, state, entity_name + )); + } + } } } diff --git a/crates/temper-spec/src/tlaplus/extractor.rs b/crates/temper-spec/src/tlaplus/extractor.rs deleted file mode 100644 index 8a25ce15..00000000 --- a/crates/temper-spec/src/tlaplus/extractor.rs +++ /dev/null @@ -1,664 +0,0 @@ -use super::types::*; - -#[derive(Debug, thiserror::Error)] -pub enum TlaExtractError { - #[error("no MODULE declaration found")] - NoModule, - #[error("no state set found (expected a set assignment like States == {{...}})")] - NoStates, - #[error("parse error: {0}")] - Parse(String), -} - -/// Extract state machine structure from a TLA+ specification. -/// -/// This is a pragmatic extractor, not a full TLA+ parser. It uses pattern -/// matching to find: -/// - MODULE name -/// - CONSTANTS and VARIABLES -/// - State set definitions (OrderStatuses == {...}) -/// - Action definitions (Name == /\ guard /\ effect) -/// - Invariants (safety properties) -/// - Liveness properties (temporal formulas with ~>) -pub fn extract_state_machine(tla_source: &str) -> Result { - let module_name = extract_module_name(tla_source)?; - let constants = extract_list_after(tla_source, "CONSTANTS"); - let variables = extract_list_after(tla_source, "VARIABLES"); - let states = extract_states(tla_source)?; - let transitions = extract_transitions(tla_source, &states); - let invariants = extract_invariants(tla_source); - let liveness_properties = extract_liveness(tla_source); - - Ok(StateMachine { - module_name, - states, - transitions, - invariants, - liveness_properties, - constants, - variables, - }) -} - -fn extract_module_name(source: &str) -> Result { - for line in source.lines() { - let trimmed = line.trim(); - if trimmed.starts_with("---- MODULE") || trimmed.starts_with("---- MODULE") { - // Format: ---- MODULE Name ---- - let parts: Vec<&str> = trimmed.split_whitespace().collect(); - if parts.len() >= 3 { - return Ok(parts[2].trim_end_matches('-').trim().to_string()); - } - } - } - Err(TlaExtractError::NoModule) -} - -fn extract_list_after(source: &str, keyword: &str) -> Vec { - let mut result = Vec::new(); - let mut in_section = false; - - for line in source.lines() { - let trimmed = line.trim(); - if trimmed.starts_with(keyword) { - in_section = true; - // Items may be on the same line - let after = trimmed.strip_prefix(keyword).unwrap_or("").trim(); - for item in after.split(',') { - let item = item.trim().trim_matches(|c| c == '\\' || c == '*'); - let item = item.trim(); - if !item.is_empty() { - result.push(item.to_string()); - } - } - continue; - } - if in_section { - if trimmed.is_empty() || trimmed.starts_with("VARIABLE") || trimmed.starts_with("----") - { - break; - } - // Strip TLA+ line comments (\*) - let without_comment = if let Some(idx) = trimmed.find("\\*") { - &trimmed[..idx] - } else { - trimmed - }; - for item in without_comment.split(',') { - let item = item - .trim() - .trim_matches(|c: char| !c.is_alphanumeric() && c != '_'); - if !item.is_empty() { - result.push(item.to_string()); - } - } - } - } - - result -} - -fn extract_states(source: &str) -> Result, TlaExtractError> { - // Look for the FIRST pattern like: XxxStatuses == {"Draft", "Submitted", ...} - // The first one is the primary entity status set. Subsequent ones (PaymentStatuses, - // ShipmentStatuses) are auxiliary and should not be included. - let mut states = Vec::new(); - - for line in source.lines() { - let trimmed = line.trim(); - if (trimmed.contains("Statuses ==") || trimmed.contains("States ==")) - && trimmed.contains('{') - { - states = extract_string_set(source, trimmed); - break; // Take only the first status set - } - } - - // Also look for state references in status variable assignments - if states.is_empty() { - // Fallback: look for status = "xxx" patterns in Init and transitions - for line in source.lines() { - let trimmed = line.trim(); - if (trimmed.contains("status =") || trimmed.contains("status' =")) - && let Some(s) = extract_quoted_string(trimmed) - && !states.contains(&s) - { - states.push(s); - } - if trimmed.contains("status \\in") { - states.extend(extract_inline_set(trimmed)); - } - } - } - - if states.is_empty() { - return Err(TlaExtractError::NoStates); - } - - Ok(states) -} - -fn extract_string_set(source: &str, start_line: &str) -> Vec { - let mut result = Vec::new(); - let mut collecting = false; - let mut buffer = String::new(); - - for line in source.lines() { - let trimmed = line.trim(); - if trimmed == start_line || (collecting && !buffer.contains('}')) { - collecting = true; - buffer.push_str(trimmed); - buffer.push(' '); - } - if collecting && buffer.contains('}') { - break; - } - } - - // Extract quoted strings from the buffer - let mut chars = buffer.chars().peekable(); - while let Some(c) = chars.next() { - if c == '"' { - let mut s = String::new(); - for c in chars.by_ref() { - if c == '"' { - break; - } - s.push(c); - } - if !s.is_empty() { - result.push(s); - } - } - } - - result -} - -/// Check if a line is a section boundary that terminates transition extraction. -fn is_section_boundary(trimmed: &str) -> bool { - trimmed.starts_with("\\*") - && (trimmed.contains("Safety Invariant") || trimmed.contains("Liveness Propert")) -} - -/// Check if a line is a Next-state relation (terminates transition extraction). -fn is_next_state_relation(trimmed: &str) -> bool { - trimmed.starts_with("Next") && trimmed.contains("==") -} - -/// Check if a line is an action definition (has `==` and is not a guard/init/meta). -fn is_action_definition(trimmed: &str) -> bool { - trimmed.contains(" ==") - && !trimmed.contains("Statuses ==") - && !trimmed.contains("States ==") - && !trimmed.contains("vars ==") - && !is_guard_definition(trimmed) - && !trimmed.starts_with("Init ==") - && !trimmed.starts_with("Next") - && !trimmed.starts_with("Spec") - && !trimmed.starts_with("ASSUME") -} - -/// Extract an action name from a definition line, preserving parens for has_parameters. -fn extract_action_name(trimmed: &str) -> Option { - let name_part = trimmed.split("==").next().unwrap_or("").trim(); - let clean = name_part.split('(').next().unwrap_or(name_part).trim(); - if !clean.is_empty() && clean.chars().next().is_some_and(|c| c.is_uppercase()) { - Some(name_part.to_string()) - } else { - None - } -} - -/// Save a completed action as a Transition (helper to reduce repetition). -fn save_action( - name: &str, - guard: &str, - effect: &str, - states: &[String], - out: &mut Vec, -) { - out.push(build_transition(name, guard, effect, states)); -} - -fn extract_transitions(source: &str, states: &[String]) -> Vec { - let mut transitions = Vec::new(); - let mut current_action: Option = None; - let mut current_guard = String::new(); - let mut current_effect = String::new(); - let mut in_action = false; - - for line in source.lines() { - let trimmed = line.trim(); - - if is_section_boundary(trimmed) || is_next_state_relation(trimmed) { - if let Some(name) = current_action.take() { - save_action( - &name, - ¤t_guard, - ¤t_effect, - states, - &mut transitions, - ); - } - break; - } - - if trimmed.starts_with("\\*") { - continue; - } - - if is_action_definition(trimmed) { - if let Some(name) = current_action.take() { - save_action( - &name, - ¤t_guard, - ¤t_effect, - states, - &mut transitions, - ); - } - if let Some(action_name) = extract_action_name(trimmed) { - current_action = Some(action_name); - current_guard.clear(); - current_effect.clear(); - in_action = true; - if let Some(rest) = trimmed.split("==").nth(1) { - categorize_line(rest.trim(), &mut current_guard, &mut current_effect); - } - } - continue; - } - - if in_action { - if trimmed.contains(" ==") && !trimmed.starts_with("/\\") && !trimmed.starts_with("\\/") - { - if let Some(name) = current_action.take() { - save_action( - &name, - ¤t_guard, - ¤t_effect, - states, - &mut transitions, - ); - } - in_action = false; - continue; - } - categorize_line(trimmed, &mut current_guard, &mut current_effect); - } - } - - if let Some(name) = current_action.take() { - save_action( - &name, - ¤t_guard, - ¤t_effect, - states, - &mut transitions, - ); - } - transitions -} - -/// Guard definitions start with "Can" and are simple predicate names without parameters. -/// They don't modify state (no primed variables). Actions like CancelOrder have parameters. -fn is_guard_definition(line: &str) -> bool { - let name = line.split("==").next().unwrap_or("").trim(); - // Guards: CanSubmit ==, CanCancel == - // NOT guards: CancelOrder(reason) == - name.starts_with("Can") && !name.contains('(') -} - -fn categorize_line(line: &str, guard: &mut String, effect: &mut String) { - let cleaned = line.trim().trim_start_matches("/\\").trim(); - if cleaned.contains("UNCHANGED") || cleaned.contains("' =") || cleaned.contains("'=") { - effect.push_str(cleaned); - effect.push('\n'); - } else { - guard.push_str(cleaned); - guard.push('\n'); - } -} - -fn build_transition(name: &str, guard: &str, effect: &str, states: &[String]) -> Transition { - let from_states = extract_from_states(guard, states); - let to_state = extract_to_state(effect, states); - // Check if the raw action definition had parameters by looking for - // parenthesized patterns in the guard (e.g., "CancelOrder(reason)") - // Since the name may already be cleaned, also check the guard for param patterns - let has_parameters = name.contains('(') - || guard.contains("\\E reason \\in") - || guard.contains("\\E item \\in") - || effect.contains("reason'") - || effect.contains("return_reason'"); - let clean_name = name.split('(').next().unwrap_or(name).to_string(); - - Transition { - name: clean_name, - from_states, - to_state, - guard_expr: guard.trim().to_string(), - has_parameters, - effect_expr: effect.trim().to_string(), - } -} - -fn extract_from_states(guard: &str, states: &[String]) -> Vec { - let mut result = Vec::new(); - - // Pattern: status = "Xxx" or status \in {"Xxx", "Yyy"} - for line in guard.lines() { - let trimmed = line.trim(); - - if trimmed.contains("status =") - && !trimmed.contains("status' =") - && let Some(s) = extract_quoted_string(trimmed) - && states.contains(&s) - && !result.contains(&s) - { - result.push(s); - } - - if trimmed.contains("status \\in") { - for s in extract_inline_set(trimmed) { - if states.contains(&s) && !result.contains(&s) { - result.push(s); - } - } - } - } - - // Also check for references like CanXxx which refer to guard definitions - // (we handle this by checking the guard expression contains state checks) - - result -} - -fn extract_to_state(effect: &str, states: &[String]) -> Option { - // Pattern: status' = "Xxx" - for line in effect.lines() { - let trimmed = line.trim(); - if trimmed.contains("status'") - && trimmed.contains('=') - && let Some(s) = extract_quoted_string(trimmed) - && states.contains(&s) - { - return Some(s); - } - } - None -} - -fn extract_quoted_string(s: &str) -> Option { - let start = s.find('"')?; - let rest = &s[start + 1..]; - let end = rest.find('"')?; - Some(rest[..end].to_string()) -} - -fn extract_inline_set(s: &str) -> Vec { - let mut result = Vec::new(); - let mut chars = s.chars().peekable(); - while let Some(c) = chars.next() { - if c == '"' { - let mut word = String::new(); - for c in chars.by_ref() { - if c == '"' { - break; - } - word.push(c); - } - if !word.is_empty() { - result.push(word); - } - } - } - result -} - -fn extract_invariants(source: &str) -> Vec { - let mut invariants = Vec::new(); - let mut current_name: Option = None; - let mut current_expr = String::new(); - - // Look for named invariants in the safety section - let mut in_invariant_section = false; - - for line in source.lines() { - let trimmed = line.trim(); - - // Detect invariant-like definitions - if trimmed.starts_with("\\*") && trimmed.contains("Safety Invariant") { - in_invariant_section = true; - continue; - } - - if trimmed.starts_with("\\*") && trimmed.contains("Liveness") { - in_invariant_section = false; - continue; - } - - // Named invariants: InvariantName == expr - if in_invariant_section - && trimmed.contains(" ==") - && !trimmed.starts_with("\\*") - && !trimmed.starts_with("SafetyInvariant") - { - // Save previous - if let Some(name) = current_name.take() { - invariants.push(Invariant { - name, - expr: current_expr.trim().to_string(), - }); - } - - let parts: Vec<&str> = trimmed.splitn(2, "==").collect(); - if parts.len() == 2 { - current_name = Some(parts[0].trim().to_string()); - current_expr = parts[1].trim().to_string(); - current_expr.push('\n'); - } - continue; - } - - if in_invariant_section && current_name.is_some() { - if trimmed.is_empty() || (trimmed.contains(" ==") && !trimmed.starts_with("/\\")) { - if let Some(name) = current_name.take() { - invariants.push(Invariant { - name, - expr: current_expr.trim().to_string(), - }); - current_expr.clear(); - } - } else { - current_expr.push_str(trimmed); - current_expr.push('\n'); - } - } - } - - // Final one - if let Some(name) = current_name { - invariants.push(Invariant { - name, - expr: current_expr.trim().to_string(), - }); - } - - invariants -} - -fn extract_liveness(source: &str) -> Vec { - let mut properties = Vec::new(); - let mut in_liveness = false; - let mut current_name: Option = None; - let mut current_expr = String::new(); - - for line in source.lines() { - let trimmed = line.trim(); - - if trimmed.starts_with("\\*") && trimmed.contains("Liveness") { - in_liveness = true; - continue; - } - - if trimmed.starts_with("\\*") - && in_liveness - && (trimmed.contains("Specification") || trimmed.contains("Model checking")) - { - in_liveness = false; - } - - if in_liveness && trimmed.contains(" ==") && !trimmed.starts_with("\\*") { - if let Some(name) = current_name.take() { - properties.push(LivenessProperty { - name, - expr: current_expr.trim().to_string(), - }); - } - - let parts: Vec<&str> = trimmed.splitn(2, "==").collect(); - if parts.len() == 2 { - current_name = Some(parts[0].trim().to_string()); - current_expr = parts[1].trim().to_string(); - current_expr.push('\n'); - } - continue; - } - - if in_liveness && current_name.is_some() { - if trimmed.is_empty() { - if let Some(name) = current_name.take() { - properties.push(LivenessProperty { - name, - expr: current_expr.trim().to_string(), - }); - current_expr.clear(); - } - } else { - current_expr.push_str(trimmed); - current_expr.push('\n'); - } - } - } - - if let Some(name) = current_name { - properties.push(LivenessProperty { - name, - expr: current_expr.trim().to_string(), - }); - } - - properties -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_reference_order_tla() { - let tla = include_str!("../../../../test-fixtures/specs/order.tla"); - let sm = extract_state_machine(tla).expect("should extract without error"); - - assert_eq!(sm.module_name, "Order"); - - // States - assert!(sm.states.contains(&"Draft".to_string())); - assert!(sm.states.contains(&"Submitted".to_string())); - assert!(sm.states.contains(&"Shipped".to_string())); - assert!(sm.states.contains(&"Refunded".to_string())); - assert_eq!(sm.states.len(), 10); - - // Constants - assert!(sm.constants.contains(&"MAX_ITEMS".to_string())); - assert!(sm.constants.contains(&"MAX_ORDER_TOTAL".to_string())); - - // Variables - assert!(sm.variables.contains(&"status".to_string())); - assert!(sm.variables.contains(&"items".to_string())); - assert!(sm.variables.contains(&"total".to_string())); - - // Transitions — should find the main actions - let transition_names: Vec<&str> = sm.transitions.iter().map(|t| t.name.as_str()).collect(); - assert!( - transition_names.contains(&"SubmitOrder"), - "should have SubmitOrder, got: {transition_names:?}" - ); - assert!(transition_names.contains(&"ConfirmOrder")); - assert!(transition_names.contains(&"ShipOrder")); - assert!(transition_names.contains(&"DeliverOrder")); - assert!( - transition_names.contains(&"CancelOrder"), - "got: {transition_names:?}" - ); - assert!(transition_names.contains(&"InitiateReturn")); - - // Verify SubmitOrder transition details - let submit = sm - .transitions - .iter() - .find(|t| t.name == "SubmitOrder") - .unwrap(); - assert_eq!(submit.to_state, Some("Submitted".to_string())); - - // Invariants - assert!(!sm.invariants.is_empty(), "should have invariants"); - let inv_names: Vec<&str> = sm.invariants.iter().map(|i| i.name.as_str()).collect(); - assert!( - inv_names.contains(&"TypeInvariant"), - "should have TypeInvariant, got: {inv_names:?}" - ); - assert!(inv_names.contains(&"ShipRequiresPayment")); - - // Liveness - assert!( - !sm.liveness_properties.is_empty(), - "should have liveness properties" - ); - let live_names: Vec<&str> = sm - .liveness_properties - .iter() - .map(|l| l.name.as_str()) - .collect(); - assert!( - live_names.contains(&"SubmittedProgress"), - "should have SubmittedProgress, got: {live_names:?}" - ); - } - - #[test] - fn test_extract_module_name() { - let source = "---- MODULE TestModule ----\n\\* Some comment\n===="; - let name = extract_module_name(source).unwrap(); - assert_eq!(name, "TestModule"); - } - - #[test] - fn test_extract_states_from_set() { - let source = r#" -States == {"Active", "Inactive", "Deleted"} -"#; - let states = extract_states(source).unwrap(); - assert_eq!(states, vec!["Active", "Inactive", "Deleted"]); - } -} - -#[cfg(test)] -mod debug { - use super::*; - #[test] - fn debug_cancel() { - let tla = include_str!("../../../../test-fixtures/specs/order.tla"); - let sm = extract_state_machine(tla).unwrap(); - for t in &sm.transitions { - if t.name.contains("Cancel") || t.name.contains("Initiate") { - eprintln!( - "{}: from={:?} to={:?} has_params={}", - t.name, t.from_states, t.to_state, t.has_parameters - ); - } - } - } -} diff --git a/crates/temper-spec/src/tlaplus/extractor/mod.rs b/crates/temper-spec/src/tlaplus/extractor/mod.rs new file mode 100644 index 00000000..303a8d6e --- /dev/null +++ b/crates/temper-spec/src/tlaplus/extractor/mod.rs @@ -0,0 +1,49 @@ +use super::types::*; + +mod properties; +mod source; +mod states; +mod transitions; + +#[cfg(test)] +mod test; + +#[derive(Debug, thiserror::Error)] +pub enum TlaExtractError { + #[error("no MODULE declaration found")] + NoModule, + #[error("no state set found (expected a set assignment like States == {{...}})")] + NoStates, + #[error("parse error: {0}")] + Parse(String), +} + +/// Extract state machine structure from a TLA+ specification. +/// +/// This is a pragmatic extractor, not a full TLA+ parser. It uses pattern +/// matching to find: +/// - MODULE name +/// - CONSTANTS and VARIABLES +/// - State set definitions (OrderStatuses == {...}) +/// - Action definitions (Name == /\ guard /\ effect) +/// - Invariants (safety properties) +/// - Liveness properties (temporal formulas with ~>) +pub fn extract_state_machine(tla_source: &str) -> Result { + let module_name = source::extract_module_name(tla_source)?; + let constants = source::extract_list_after(tla_source, "CONSTANTS"); + let variables = source::extract_list_after(tla_source, "VARIABLES"); + let states = states::extract_states(tla_source)?; + let transitions = transitions::extract_transitions(tla_source, &states); + let invariants = properties::extract_invariants(tla_source); + let liveness_properties = properties::extract_liveness(tla_source); + + Ok(StateMachine { + module_name, + states, + transitions, + invariants, + liveness_properties, + constants, + variables, + }) +} diff --git a/crates/temper-spec/src/tlaplus/extractor/properties.rs b/crates/temper-spec/src/tlaplus/extractor/properties.rs new file mode 100644 index 00000000..8f8e9eef --- /dev/null +++ b/crates/temper-spec/src/tlaplus/extractor/properties.rs @@ -0,0 +1,125 @@ +use super::{Invariant, LivenessProperty}; + +pub(super) fn extract_invariants(source: &str) -> Vec { + extract_named_formulas( + source, + |line| line.starts_with("\\*") && line.contains("Safety Invariant"), + |line| line.starts_with("\\*") && line.contains("Liveness"), + |line| line.starts_with("SafetyInvariant"), + true, + ) + .into_iter() + .map(|formula| Invariant { + name: formula.name, + expr: formula.expr, + }) + .collect() +} + +pub(super) fn extract_liveness(source: &str) -> Vec { + extract_named_formulas( + source, + |line| line.starts_with("\\*") && line.contains("Liveness"), + |line| { + line.starts_with("\\*") + && (line.contains("Specification") || line.contains("Model checking")) + }, + |_| false, + false, + ) + .into_iter() + .map(|formula| LivenessProperty { + name: formula.name, + expr: formula.expr, + }) + .collect() +} + +struct NamedFormula { + name: String, + expr: String, +} + +fn extract_named_formulas( + source: &str, + starts_section: impl Fn(&str) -> bool, + ends_section: impl Fn(&str) -> bool, + skips_definition: impl Fn(&str) -> bool, + blank_line_ends_formula: bool, +) -> Vec { + let mut formulas = Vec::new(); + let mut current_name = None; + let mut current_expr = String::new(); + let mut in_section = false; + + for line in source.lines() { + let trimmed = line.trim(); + + if starts_section(trimmed) { + in_section = true; + continue; + } + + if in_section && ends_section(trimmed) { + flush_formula(&mut formulas, &mut current_name, &mut current_expr); + in_section = false; + continue; + } + + if !in_section { + continue; + } + + if is_named_definition(trimmed, &skips_definition) { + flush_formula(&mut formulas, &mut current_name, &mut current_expr); + start_formula(trimmed, &mut current_name, &mut current_expr); + continue; + } + + if current_name.is_some() && ends_formula(trimmed, blank_line_ends_formula) { + flush_formula(&mut formulas, &mut current_name, &mut current_expr); + continue; + } + + if current_name.is_some() { + current_expr.push_str(trimmed); + current_expr.push('\n'); + } + } + + flush_formula(&mut formulas, &mut current_name, &mut current_expr); + formulas +} + +fn is_named_definition(trimmed: &str, skips_definition: &impl Fn(&str) -> bool) -> bool { + trimmed.contains(" ==") && !trimmed.starts_with("\\*") && !skips_definition(trimmed) +} + +fn start_formula(line: &str, current_name: &mut Option, current_expr: &mut String) { + let parts: Vec<&str> = line.splitn(2, "==").collect(); + if parts.len() == 2 { + *current_name = Some(parts[0].trim().to_string()); + current_expr.clear(); + current_expr.push_str(parts[1].trim()); + current_expr.push('\n'); + } +} + +fn ends_formula(trimmed: &str, blank_line_ends_formula: bool) -> bool { + trimmed.is_empty() + || (blank_line_ends_formula && trimmed.contains(" ==") && !trimmed.starts_with("/\\")) +} + +fn flush_formula( + formulas: &mut Vec, + current_name: &mut Option, + current_expr: &mut String, +) { + if let Some(name) = current_name.take() { + formulas.push(NamedFormula { + name, + expr: current_expr.trim().to_string(), + }); + current_expr.clear(); + } +} diff --git a/crates/temper-spec/src/tlaplus/extractor/source.rs b/crates/temper-spec/src/tlaplus/extractor/source.rs new file mode 100644 index 00000000..d0696a6e --- /dev/null +++ b/crates/temper-spec/src/tlaplus/extractor/source.rs @@ -0,0 +1,94 @@ +use super::TlaExtractError; + +pub(super) fn extract_module_name(source: &str) -> Result { + for line in source.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("---- MODULE") { + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() >= 3 { + return Ok(parts[2].trim_end_matches('-').trim().to_string()); + } + } + } + + Err(TlaExtractError::NoModule) +} + +pub(super) fn extract_list_after(source: &str, keyword: &str) -> Vec { + let mut result = Vec::new(); + let mut in_section = false; + + for line in source.lines() { + let trimmed = line.trim(); + if trimmed.starts_with(keyword) { + in_section = true; + collect_list_items(trimmed.strip_prefix(keyword).unwrap_or(""), &mut result); + continue; + } + + if !in_section { + continue; + } + + if trimmed.is_empty() || trimmed.starts_with("VARIABLE") || trimmed.starts_with("----") { + break; + } + + let without_comment = trimmed.split("\\*").next().unwrap_or(trimmed); + collect_identifier_items(without_comment, &mut result); + } + + result +} + +pub(super) fn extract_quoted_string(input: &str) -> Option { + let start = input.find('"')?; + let rest = &input[start + 1..]; + let end = rest.find('"')?; + Some(rest[..end].to_string()) +} + +pub(super) fn extract_inline_set(input: &str) -> Vec { + let mut result = Vec::new(); + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch != '"' { + continue; + } + + let mut value = String::new(); + for ch in chars.by_ref() { + if ch == '"' { + break; + } + value.push(ch); + } + + if !value.is_empty() { + result.push(value); + } + } + + result +} + +fn collect_list_items(fragment: &str, out: &mut Vec) { + for item in fragment.split(',') { + let item = item.trim().trim_matches(|c| c == '\\' || c == '*').trim(); + if !item.is_empty() { + out.push(item.to_string()); + } + } +} + +fn collect_identifier_items(fragment: &str, out: &mut Vec) { + for item in fragment.split(',') { + let item = item + .trim() + .trim_matches(|c: char| !c.is_alphanumeric() && c != '_'); + if !item.is_empty() { + out.push(item.to_string()); + } + } +} diff --git a/crates/temper-spec/src/tlaplus/extractor/states.rs b/crates/temper-spec/src/tlaplus/extractor/states.rs new file mode 100644 index 00000000..a1cb1512 --- /dev/null +++ b/crates/temper-spec/src/tlaplus/extractor/states.rs @@ -0,0 +1,80 @@ +use super::TlaExtractError; +use super::source::{extract_inline_set, extract_quoted_string}; + +pub(super) fn extract_states(source: &str) -> Result, TlaExtractError> { + let mut states = extract_declared_states(source); + if states.is_empty() { + states = extract_fallback_states(source); + } + + if states.is_empty() { + return Err(TlaExtractError::NoStates); + } + + Ok(states) +} + +fn extract_declared_states(source: &str) -> Vec { + for line in source.lines() { + let trimmed = line.trim(); + if (trimmed.contains("Statuses ==") || trimmed.contains("States ==")) + && trimmed.contains('{') + { + return extract_string_set(source, trimmed); + } + } + + Vec::new() +} + +fn extract_fallback_states(source: &str) -> Vec { + let mut states = Vec::new(); + + for line in source.lines() { + let trimmed = line.trim(); + if let Some(state) = extract_status_assignment(trimmed) + && !states.contains(&state) + { + states.push(state); + } + + if trimmed.contains("status \\in") { + for state in extract_inline_set(trimmed) { + if !states.contains(&state) { + states.push(state); + } + } + } + } + + states +} + +fn extract_string_set(source: &str, start_line: &str) -> Vec { + let mut buffer = String::new(); + let mut collecting = false; + + for line in source.lines() { + let trimmed = line.trim(); + if trimmed == start_line || (collecting && !buffer.contains('}')) { + collecting = true; + buffer.push_str(trimmed); + buffer.push(' '); + } + if collecting && buffer.contains('}') { + break; + } + } + + extract_inline_set(&buffer) +} + +fn extract_status_assignment(line: &str) -> Option { + if (line.contains("status =") || line.contains("status' =")) + && let Some(state) = extract_quoted_string(line) + { + return Some(state); + } + + None +} diff --git a/crates/temper-spec/src/tlaplus/extractor/test.rs b/crates/temper-spec/src/tlaplus/extractor/test.rs new file mode 100644 index 00000000..28567995 --- /dev/null +++ b/crates/temper-spec/src/tlaplus/extractor/test.rs @@ -0,0 +1,120 @@ +use super::*; + +#[test] +fn test_extract_reference_order_tla() { + let tla = include_str!("../../../../../test-fixtures/specs/order.tla"); + let sm = extract_state_machine(tla).expect("should extract without error"); + + assert_eq!(sm.module_name, "Order"); + assert_reference_states(&sm); + assert_reference_constants(&sm); + assert_reference_variables(&sm); + assert_reference_transitions(&sm); + assert_reference_invariants(&sm); + assert_reference_liveness(&sm); +} + +#[test] +fn test_extract_module_name() { + let source = "---- MODULE TestModule ----\n\\* Some comment\n===="; + let name = source::extract_module_name(source).unwrap(); + assert_eq!(name, "TestModule"); +} + +#[test] +fn test_extract_states_from_set() { + let source = r#" +States == {"Active", "Inactive", "Deleted"} +"#; + let states = states::extract_states(source).unwrap(); + assert_eq!(states, vec!["Active", "Inactive", "Deleted"]); +} + +#[test] +fn debug_cancel() { + let tla = include_str!("../../../../../test-fixtures/specs/order.tla"); + let sm = extract_state_machine(tla).unwrap(); + for transition in &sm.transitions { + if transition.name.contains("Cancel") || transition.name.contains("Initiate") { + eprintln!( + "{}: from={:?} to={:?} has_params={}", + transition.name, + transition.from_states, + transition.to_state, + transition.has_parameters + ); + } + } +} + +fn assert_reference_states(sm: &StateMachine) { + assert!(sm.states.contains(&"Draft".to_string())); + assert!(sm.states.contains(&"Submitted".to_string())); + assert!(sm.states.contains(&"Shipped".to_string())); + assert!(sm.states.contains(&"Refunded".to_string())); + assert_eq!(sm.states.len(), 10); +} + +fn assert_reference_constants(sm: &StateMachine) { + assert!(sm.constants.contains(&"MAX_ITEMS".to_string())); + assert!(sm.constants.contains(&"MAX_ORDER_TOTAL".to_string())); +} + +fn assert_reference_variables(sm: &StateMachine) { + assert!(sm.variables.contains(&"status".to_string())); + assert!(sm.variables.contains(&"items".to_string())); + assert!(sm.variables.contains(&"total".to_string())); +} + +fn assert_reference_transitions(sm: &StateMachine) { + let transition_names: Vec<&str> = sm.transitions.iter().map(|t| t.name.as_str()).collect(); + assert!( + transition_names.contains(&"SubmitOrder"), + "should have SubmitOrder, got: {transition_names:?}" + ); + assert!(transition_names.contains(&"ConfirmOrder")); + assert!(transition_names.contains(&"ShipOrder")); + assert!(transition_names.contains(&"DeliverOrder")); + assert!( + transition_names.contains(&"CancelOrder"), + "got: {transition_names:?}" + ); + assert!(transition_names.contains(&"InitiateReturn")); + + let submit = sm + .transitions + .iter() + .find(|transition| transition.name == "SubmitOrder") + .unwrap(); + assert_eq!(submit.to_state, Some("Submitted".to_string())); +} + +fn assert_reference_invariants(sm: &StateMachine) { + assert!(!sm.invariants.is_empty(), "should have invariants"); + let names: Vec<&str> = sm + .invariants + .iter() + .map(|invariant| invariant.name.as_str()) + .collect(); + assert!( + names.contains(&"TypeInvariant"), + "should have TypeInvariant, got: {names:?}" + ); + assert!(names.contains(&"ShipRequiresPayment")); +} + +fn assert_reference_liveness(sm: &StateMachine) { + assert!( + !sm.liveness_properties.is_empty(), + "should have liveness properties" + ); + let names: Vec<&str> = sm + .liveness_properties + .iter() + .map(|property| property.name.as_str()) + .collect(); + assert!( + names.contains(&"SubmittedProgress"), + "should have SubmittedProgress, got: {names:?}" + ); +} diff --git a/crates/temper-spec/src/tlaplus/extractor/transitions.rs b/crates/temper-spec/src/tlaplus/extractor/transitions.rs new file mode 100644 index 00000000..3921ae71 --- /dev/null +++ b/crates/temper-spec/src/tlaplus/extractor/transitions.rs @@ -0,0 +1,189 @@ +use super::Transition; +use super::source::{extract_inline_set, extract_quoted_string}; + +pub(super) fn extract_transitions(source: &str, states: &[String]) -> Vec { + let mut transitions = Vec::new(); + let mut current = ActionBuffer::default(); + + for line in source.lines() { + let trimmed = line.trim(); + + if is_section_boundary(trimmed) || is_next_state_relation(trimmed) { + current.finish(states, &mut transitions); + break; + } + + if trimmed.starts_with("\\*") { + continue; + } + + if is_action_definition(trimmed) { + current.finish(states, &mut transitions); + current.start(trimmed); + continue; + } + + current.push_line(trimmed, states, &mut transitions); + } + + current.finish(states, &mut transitions); + transitions +} + +#[derive(Default)] +struct ActionBuffer { + name: Option, + guard: String, + effect: String, + in_action: bool, +} + +impl ActionBuffer { + fn start(&mut self, definition: &str) { + if let Some(action_name) = extract_action_name(definition) { + self.name = Some(action_name); + self.guard.clear(); + self.effect.clear(); + self.in_action = true; + if let Some(rest) = definition.split("==").nth(1) { + categorize_line(rest.trim(), &mut self.guard, &mut self.effect); + } + } + } + + fn push_line(&mut self, line: &str, states: &[String], out: &mut Vec) { + if !self.in_action { + return; + } + + if line.contains(" ==") && !line.starts_with("/\\") && !line.starts_with("\\/") { + self.finish(states, out); + self.in_action = false; + return; + } + + categorize_line(line, &mut self.guard, &mut self.effect); + } + + fn finish(&mut self, states: &[String], out: &mut Vec) { + if let Some(name) = self.name.take() { + out.push(build_transition(&name, &self.guard, &self.effect, states)); + } + } +} + +fn is_section_boundary(trimmed: &str) -> bool { + trimmed.starts_with("\\*") + && (trimmed.contains("Safety Invariant") || trimmed.contains("Liveness Propert")) +} + +fn is_next_state_relation(trimmed: &str) -> bool { + trimmed.starts_with("Next") && trimmed.contains("==") +} + +fn is_action_definition(trimmed: &str) -> bool { + trimmed.contains(" ==") + && !trimmed.contains("Statuses ==") + && !trimmed.contains("States ==") + && !trimmed.contains("vars ==") + && !is_guard_definition(trimmed) + && !trimmed.starts_with("Init ==") + && !trimmed.starts_with("Next") + && !trimmed.starts_with("Spec") + && !trimmed.starts_with("ASSUME") +} + +fn is_guard_definition(line: &str) -> bool { + let name = line.split("==").next().unwrap_or("").trim(); + name.starts_with("Can") && !name.contains('(') +} + +fn extract_action_name(trimmed: &str) -> Option { + let name_part = trimmed.split("==").next().unwrap_or("").trim(); + let clean = name_part.split('(').next().unwrap_or(name_part).trim(); + if !clean.is_empty() && clean.chars().next().is_some_and(|ch| ch.is_uppercase()) { + Some(name_part.to_string()) + } else { + None + } +} + +fn categorize_line(line: &str, guard: &mut String, effect: &mut String) { + let cleaned = line.trim().trim_start_matches("/\\").trim(); + let target = + if cleaned.contains("UNCHANGED") || cleaned.contains("' =") || cleaned.contains("'=") { + effect + } else { + guard + }; + target.push_str(cleaned); + target.push('\n'); +} + +fn build_transition(name: &str, guard: &str, effect: &str, states: &[String]) -> Transition { + let has_parameters = name.contains('(') + || guard.contains("\\E reason \\in") + || guard.contains("\\E item \\in") + || effect.contains("reason'") + || effect.contains("return_reason'"); + + Transition { + name: name.split('(').next().unwrap_or(name).to_string(), + from_states: extract_from_states(guard, states), + to_state: extract_to_state(effect, states), + guard_expr: guard.trim().to_string(), + has_parameters, + effect_expr: effect.trim().to_string(), + } +} + +fn extract_from_states(guard: &str, states: &[String]) -> Vec { + let mut result = Vec::new(); + + for line in guard.lines() { + let trimmed = line.trim(); + + if let Some(state) = extract_guard_state(trimmed, states) + && !result.contains(&state) + { + result.push(state); + } + + if trimmed.contains("status \\in") { + for state in extract_inline_set(trimmed) { + if states.contains(&state) && !result.contains(&state) { + result.push(state); + } + } + } + } + + result +} + +fn extract_guard_state(line: &str, states: &[String]) -> Option { + if line.contains("status =") + && !line.contains("status' =") + && let Some(state) = extract_quoted_string(line) + && states.contains(&state) + { + return Some(state); + } + + None +} + +fn extract_to_state(effect: &str, states: &[String]) -> Option { + for line in effect.lines() { + let trimmed = line.trim(); + if trimmed.contains("status'") + && trimmed.contains('=') + && let Some(state) = extract_quoted_string(trimmed) + && states.contains(&state) + { + return Some(state); + } + } + + None +} From 0c70243941477e6c2ab21137c93519f352c40e4b Mon Sep 17 00:00:00 2001 From: rita-aga Date: Tue, 24 Mar 2026 15:53:17 -0400 Subject: [PATCH 2/3] Remove unwrap from guard translation --- crates/temper-spec/src/automaton/translate.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/temper-spec/src/automaton/translate.rs b/crates/temper-spec/src/automaton/translate.rs index d9b4c595..f6a361dd 100644 --- a/crates/temper-spec/src/automaton/translate.rs +++ b/crates/temper-spec/src/automaton/translate.rs @@ -172,7 +172,7 @@ fn translate_guards(from_states: &[String], guards: &[Guard]) -> ResolvedGuard { match resolved.len() { 0 => ResolvedGuard::Always, - 1 => resolved.into_iter().next().unwrap(), // ci-ok: len() == 1 + 1 => resolved.remove(0), _ => ResolvedGuard::And(resolved), } } From 918c0bbacc0f8b48fe82763afac26184418b3cb3 Mon Sep 17 00:00:00 2001 From: rita-aga Date: Tue, 24 Mar 2026 16:00:13 -0400 Subject: [PATCH 3/3] Rename test modules for CI integrity scan --- ...arser_test_core.rs => parser_core_test.rs} | 0 ...st_features.rs => parser_features_test.rs} | 0 ...rations.rs => parser_integrations_test.rs} | 0 .../temper-spec/src/automaton/parser_test.rs | 8 +++---- ...st_triggers.rs => parser_triggers_test.rs} | 0 .../src/automaton/toml_parser/mod.rs | 2 +- .../toml_parser/{test.rs => tests.rs} | 22 +++++-------------- crates/temper-spec/src/csdl/parser/mod.rs | 2 +- .../src/csdl/parser/{test.rs => tests.rs} | 0 .../temper-spec/src/tlaplus/extractor/mod.rs | 3 ++- .../tlaplus/extractor/{test.rs => tests.rs} | 0 11 files changed, 13 insertions(+), 24 deletions(-) rename crates/temper-spec/src/automaton/{parser_test_core.rs => parser_core_test.rs} (100%) rename crates/temper-spec/src/automaton/{parser_test_features.rs => parser_features_test.rs} (100%) rename crates/temper-spec/src/automaton/{parser_test_integrations.rs => parser_integrations_test.rs} (100%) rename crates/temper-spec/src/automaton/{parser_test_triggers.rs => parser_triggers_test.rs} (100%) rename crates/temper-spec/src/automaton/toml_parser/{test.rs => tests.rs} (81%) rename crates/temper-spec/src/csdl/parser/{test.rs => tests.rs} (100%) rename crates/temper-spec/src/tlaplus/extractor/{test.rs => tests.rs} (100%) diff --git a/crates/temper-spec/src/automaton/parser_test_core.rs b/crates/temper-spec/src/automaton/parser_core_test.rs similarity index 100% rename from crates/temper-spec/src/automaton/parser_test_core.rs rename to crates/temper-spec/src/automaton/parser_core_test.rs diff --git a/crates/temper-spec/src/automaton/parser_test_features.rs b/crates/temper-spec/src/automaton/parser_features_test.rs similarity index 100% rename from crates/temper-spec/src/automaton/parser_test_features.rs rename to crates/temper-spec/src/automaton/parser_features_test.rs diff --git a/crates/temper-spec/src/automaton/parser_test_integrations.rs b/crates/temper-spec/src/automaton/parser_integrations_test.rs similarity index 100% rename from crates/temper-spec/src/automaton/parser_test_integrations.rs rename to crates/temper-spec/src/automaton/parser_integrations_test.rs diff --git a/crates/temper-spec/src/automaton/parser_test.rs b/crates/temper-spec/src/automaton/parser_test.rs index 31ab022c..6874f61b 100644 --- a/crates/temper-spec/src/automaton/parser_test.rs +++ b/crates/temper-spec/src/automaton/parser_test.rs @@ -1,10 +1,10 @@ pub(super) const ORDER_IOA: &str = include_str!("../../../../test-fixtures/specs/order.ioa.toml"); -#[path = "parser_test_core.rs"] +#[path = "parser_core_test.rs"] mod core; -#[path = "parser_test_features.rs"] +#[path = "parser_features_test.rs"] mod features; -#[path = "parser_test_integrations.rs"] +#[path = "parser_integrations_test.rs"] mod integrations; -#[path = "parser_test_triggers.rs"] +#[path = "parser_triggers_test.rs"] mod triggers; diff --git a/crates/temper-spec/src/automaton/parser_test_triggers.rs b/crates/temper-spec/src/automaton/parser_triggers_test.rs similarity index 100% rename from crates/temper-spec/src/automaton/parser_test_triggers.rs rename to crates/temper-spec/src/automaton/parser_triggers_test.rs diff --git a/crates/temper-spec/src/automaton/toml_parser/mod.rs b/crates/temper-spec/src/automaton/toml_parser/mod.rs index 0350410b..7b54f5b0 100644 --- a/crates/temper-spec/src/automaton/toml_parser/mod.rs +++ b/crates/temper-spec/src/automaton/toml_parser/mod.rs @@ -364,5 +364,5 @@ fn extract_agent_triggers(source: &str) -> Vec { } #[cfg(test)] -#[path = "test.rs"] +#[path = "tests.rs"] mod tests; diff --git a/crates/temper-spec/src/automaton/toml_parser/test.rs b/crates/temper-spec/src/automaton/toml_parser/tests.rs similarity index 81% rename from crates/temper-spec/src/automaton/toml_parser/test.rs rename to crates/temper-spec/src/automaton/toml_parser/tests.rs index 2338bdfa..f3ee31a3 100644 --- a/crates/temper-spec/src/automaton/toml_parser/test.rs +++ b/crates/temper-spec/src/automaton/toml_parser/tests.rs @@ -1,13 +1,11 @@ use super::inline::{parse_inline_fields, split_inline_tables}; use super::*; -// ── parse_kv ────────────────────────────────────────────── - #[test] fn parse_kv_simple() { - let (key, val) = parse_kv("name = \"Order\"").unwrap(); + let (key, value) = parse_kv("name = \"Order\"").unwrap(); assert_eq!(key, "name"); - assert_eq!(val, "Order"); + assert_eq!(value, "Order"); } #[test] @@ -17,13 +15,11 @@ fn parse_kv_no_equals() { #[test] fn parse_kv_trims_whitespace() { - let (key, val) = parse_kv(" key = \"value\" ").unwrap(); + let (key, value) = parse_kv(" key = \"value\" ").unwrap(); assert_eq!(key, "key"); - assert_eq!(val, "value"); + assert_eq!(value, "value"); } -// ── parse_string_array ──────────────────────────────────── - #[test] fn parse_string_array_simple() { let arr = parse_string_array("[\"Draft\", \"Active\", \"Done\"]"); @@ -42,8 +38,6 @@ fn parse_string_array_empty_brackets() { assert!(arr.is_empty()); } -// ── split_inline_tables ─────────────────────────────────── - #[test] fn split_inline_tables_two_items() { let result = split_inline_tables("{a = 1}, {b = 2}"); @@ -58,8 +52,6 @@ fn split_inline_tables_empty() { assert!(result.is_empty()); } -// ── parse_inline_fields ─────────────────────────────────── - #[test] fn parse_inline_fields_simple() { let map = parse_inline_fields("type = \"schedule\", action = \"Refresh\""); @@ -85,8 +77,6 @@ fn parse_inline_fields_empty() { assert!(map.is_empty()); } -// ── join_multiline_arrays ───────────────────────────────── - #[test] fn join_multiline_single_line() { let result = join_multiline_arrays("key = [\"a\", \"b\"]"); @@ -100,7 +90,7 @@ fn join_multiline_continuation() { let result = join_multiline_arrays(input); assert_eq!(result.len(), 1); assert!(result[0].contains("effect = [")); - assert!(result[0].contains("]")); + assert!(result[0].contains(']')); } #[test] @@ -110,8 +100,6 @@ fn join_multiline_no_brackets() { assert_eq!(result.len(), 2); } -// ── parse_guard_clause ──────────────────────────────────── - #[test] fn guard_gt() { let g = parse_guard_clause("items > 3").unwrap(); diff --git a/crates/temper-spec/src/csdl/parser/mod.rs b/crates/temper-spec/src/csdl/parser/mod.rs index 37f67258..f5df9c4b 100644 --- a/crates/temper-spec/src/csdl/parser/mod.rs +++ b/crates/temper-spec/src/csdl/parser/mod.rs @@ -52,5 +52,5 @@ pub fn parse_csdl(xml: &str) -> Result { } #[cfg(test)] -#[path = "test.rs"] +#[path = "tests.rs"] mod tests; diff --git a/crates/temper-spec/src/csdl/parser/test.rs b/crates/temper-spec/src/csdl/parser/tests.rs similarity index 100% rename from crates/temper-spec/src/csdl/parser/test.rs rename to crates/temper-spec/src/csdl/parser/tests.rs diff --git a/crates/temper-spec/src/tlaplus/extractor/mod.rs b/crates/temper-spec/src/tlaplus/extractor/mod.rs index 303a8d6e..5d713cc7 100644 --- a/crates/temper-spec/src/tlaplus/extractor/mod.rs +++ b/crates/temper-spec/src/tlaplus/extractor/mod.rs @@ -6,7 +6,8 @@ mod states; mod transitions; #[cfg(test)] -mod test; +#[path = "tests.rs"] +mod tests; #[derive(Debug, thiserror::Error)] pub enum TlaExtractError { diff --git a/crates/temper-spec/src/tlaplus/extractor/test.rs b/crates/temper-spec/src/tlaplus/extractor/tests.rs similarity index 100% rename from crates/temper-spec/src/tlaplus/extractor/test.rs rename to crates/temper-spec/src/tlaplus/extractor/tests.rs