diff --git a/Cargo.lock b/Cargo.lock index 0bcadc1..7ded291 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "anyhow" version = "1.0.98" @@ -43,6 +52,7 @@ dependencies = [ "itertools", "pad", "rand", + "regex", "serde", "serde_yml", "thiserror 1.0.69", @@ -390,6 +400,35 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "rustix" version = "1.0.7" diff --git a/Cargo.toml b/Cargo.toml index ea8cc40..b4232c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,10 +11,11 @@ thiserror = "1" rand = "0.8" graphviz-rust = "0.9.4" itertools = "0.14.0" +regex = "1.11.1" [dependencies.uuid] version = "1.17.0" # Lets you generate random UUIDs features = [ "v4", -] \ No newline at end of file +] diff --git a/src/configotron/fsm/display.rs b/src/configotron/fsm/display.rs index 3487443..82d2312 100644 --- a/src/configotron/fsm/display.rs +++ b/src/configotron/fsm/display.rs @@ -1,4 +1,6 @@ -use crate::configotron::fsm::TransitionMask; +use crate::configotron::fsm::{masks::decompose_transition, FiniteStateMachine, TransitionMask}; +use graphviz_rust::dot_structures::{Attribute, Edge, EdgeTy, Graph, Id, Node, NodeId, Stmt, Vertex}; +use uuid::Uuid; @@ -71,9 +73,70 @@ pub fn mask_to_string(mask: TransitionMask) -> String { } +pub fn create_graphviz_graph(fsm: &FiniteStateMachine) -> Graph { + let mut stmts: Vec = Vec::new(); + + let mut unamed_state_count: u32 = 0; + let mut states_sorted: Vec<_> = fsm.states.iter().collect(); + states_sorted.sort_by(|a, b| a.1.name.cmp(&b.1.name)); + + fn format_id(id: &Uuid) -> Id { + Id::Plain(format!("\"{}\"", id.to_string())) + } + + for (k, state) in states_sorted { + // Adding all nodes + let label = if state.name.len() == 0 { + format!("\"z{}\"", unamed_state_count) + } else { + format!("\"{}\"", state.name) + }; + + unamed_state_count += 1; + + let shape = match state.state_type { + super::StateType::Terminal => "doublecircle".to_string(), + super::StateType::Error => "circle".to_string(), + super::StateType::None => "circle".to_string(), + }; + + let color = match state.state_type { + super::StateType::Terminal => "black".to_string(), + super::StateType::Error => "red".to_string(), + super::StateType::None => "black".to_string(), + }; + + stmts.push(Stmt::Node(Node { + id: NodeId(format_id(k), None), + attributes: vec![ + Attribute(Id::Plain("label".to_string()), Id::Plain(label)), + Attribute(Id::Plain("shape".to_string()), Id::Plain(shape)), + Attribute(Id::Plain("color".to_string()), Id::Plain(color)) + ] + })); + + // Adding all edges + + for ((state_ref, action), mask) in decompose_transition(&state.transition) { + stmts.push(Stmt::Edge(Edge { + ty: EdgeTy::Pair( + Vertex::N(NodeId(format_id(k), None)), + Vertex::N(NodeId(format_id(&state_ref), None)) + ), + attributes: vec![ + Attribute(Id::Plain("label".to_string()), Id::Plain(format!("\"{}\\n/{}/\"", mask_to_string(mask), action))) + ] + })); + } + } + Graph::DiGraph { id: Id::Plain("FSM".to_string()), strict: true, stmts: stmts } +} + #[cfg(test)] mod test { - use crate::configotron::fsm::{display::mask_to_string, masks::{mask_all, mask_char, mask_char_range}}; + use graphviz_rust::{cmd::{CommandArg, Format}, exec, printer::{DotPrinter, PrinterContext}}; + + use crate::configotron::fsm::{display::{create_graphviz_graph, mask_to_string}, masks::{mask_all, mask_char, mask_char_range}, Action, FSMBuilder, StateType}; #[test] fn mask_to_string_test() { @@ -111,4 +174,64 @@ mod test { "'a','c','e'..'g','i','k'..'m','o','q','s','u','w','y','z'".to_string() ); } + + #[test] + fn create_graph_dot_lang() { + let mut fsm_builder = FSMBuilder::new(); + let z0 = fsm_builder.add_state("z0".to_string(), StateType::None); + let z1 = fsm_builder.add_state("z1".to_string(), StateType::None); + let z2 = fsm_builder.add_state("z2".to_string(), StateType::Terminal); + let z3 = fsm_builder.add_state("z3".to_string(), StateType::Error); + + // Transitions: + // z0: 'a' -> z1, 'b' -> z2 + // z1: 'a' -> z1, 'b' -> z2 + // z2: 'a' -> z2 + // z3: * -> z3 + // Error/Default: z3 + + fsm_builder.add_link(&z0, &z1, Action::None, mask_char('a')).unwrap(); + fsm_builder.add_link(&z0, &z2, Action::None, mask_char('b')).unwrap(); + + fsm_builder.add_link(&z1, &z1, Action::None, mask_char('a')).unwrap(); + fsm_builder.add_link(&z1, &z2, Action::None, mask_char('b')).unwrap(); + + fsm_builder.add_link(&z2, &z2, Action::None, mask_char('a')).unwrap(); + + let res_fsm = fsm_builder.finish(&z3, Action::None, &z0); + assert!(res_fsm.is_ok()); + if let Ok(fsm) = res_fsm { + let graph = create_graphviz_graph(&fsm); + + let string = graph.print(&mut PrinterContext::default()); + println!("Original: {}", string); + + // Test that the DOT output contains the expected structure, ignoring node ids (UUIDs) + let string = graph.print(&mut PrinterContext::default()); + // Remove all UUIDs from the output for comparison + let normalized = regex::Regex::new(r#""[0-9a-fA-F\-]{36}""#) + .unwrap() + .replace_all(&string, "\"UUID\""); + // Check for expected nodes and edges (labels, shapes, colors, etc.) + assert!(normalized.contains("label=\"z0\"")); + assert!(normalized.contains("label=\"z1\"")); + assert!(normalized.contains("label=\"z2\"")); + assert!(normalized.contains("label=\"z3\"")); + assert!(normalized.contains("shape=doublecircle")); + assert!(normalized.contains("shape=circle")); + assert!(normalized.contains("color=red")); + assert!(normalized.contains("color=black")); + assert!(normalized.contains("label=\"'a'\\n/None/\"")); + assert!(normalized.contains("label=\"'b'\\n/None/\"")); + // Check that all expected edges exist (ignoring UUIDs) + let expected_edges = [ + "label=\"'a'\\n/None/\"", + "label=\"'b'\\n/None/\"", + ]; + for edge in expected_edges.iter() { + assert!(normalized.matches(edge).count() >= 2, "Missing edge: {}", edge); + } + + } + } } \ No newline at end of file diff --git a/src/configotron/fsm/masks.rs b/src/configotron/fsm/masks.rs index 0b519b0..32aab03 100644 --- a/src/configotron/fsm/masks.rs +++ b/src/configotron/fsm/masks.rs @@ -2,7 +2,7 @@ use std::{collections::{HashMap}, hash::Hash}; use crate::configotron::fsm::{TransitionArray, TransitionMask}; -pub fn decompose_transition(array: &TransitionArray) -> Vec<(T, TransitionMask)> { +pub fn decompose_transition(array: &TransitionArray) -> Vec<(T, TransitionMask)> { let mut results: HashMap = HashMap::new(); for (pos, i) in array.iter().enumerate() { diff --git a/src/configotron/fsm/mod.rs b/src/configotron/fsm/mod.rs index 39084ad..199d103 100644 --- a/src/configotron/fsm/mod.rs +++ b/src/configotron/fsm/mod.rs @@ -1,7 +1,8 @@ pub mod display; pub mod masks; -use std::{collections::{HashMap, HashSet}}; +use std::{collections::{HashMap, HashSet}, fmt}; + use uuid::Uuid; @@ -9,12 +10,12 @@ pub type StateRef = Uuid; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum StateType { - EndState, - ErrorState, + Terminal, + Error, None } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum Action { None, Push, @@ -22,6 +23,17 @@ pub enum Action { Submit } +impl fmt::Display for Action { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Action::None => write!(f, "None"), + Action::Push => write!(f, "Push"), + Action::Pop => write!(f, "Pop"), + Action::Submit => write!(f, "Submit"), + } + } +} + pub type TransitionArray = [T; 128]; pub type UnfinishedTransitionArray = [Option; 128]; @@ -162,12 +174,12 @@ mod test { let mut fsm_builder = FSMBuilder::new(); let z0 = fsm_builder.add_state("z0".to_string(), StateType::None); let z1 = fsm_builder.add_state("z1".to_string(), StateType::None); - let z2 = fsm_builder.add_state("z2".to_string(), StateType::EndState); - let z3 = fsm_builder.add_state("z3".to_string(), StateType::ErrorState); + let z2 = fsm_builder.add_state("z2".to_string(), StateType::Terminal); + let z3 = fsm_builder.add_state("z3".to_string(), StateType::Error); // These are unsed states and should be eliminated - fsm_builder.add_state("u4".to_string(), StateType::ErrorState); - let u5 =fsm_builder.add_state("u5".to_string(), StateType::ErrorState); + fsm_builder.add_state("u4".to_string(), StateType::Error); + let u5 =fsm_builder.add_state("u5".to_string(), StateType::Error); fsm_builder.add_link(&u5, &u5, Action::None, mask_all()).unwrap(); @@ -199,22 +211,22 @@ mod test { assert_eq!(run_fsm(&fsm, &"".to_string()), StateType::None); assert_eq!(run_fsm(&fsm, &"a".to_string()), StateType::None); - assert_eq!(run_fsm(&fsm, &"b".to_string()), StateType::EndState); + assert_eq!(run_fsm(&fsm, &"b".to_string()), StateType::Terminal); assert_eq!(run_fsm(&fsm, &"aa".to_string()), StateType::None); - assert_eq!(run_fsm(&fsm, &"ab".to_string()), StateType::EndState); - assert_eq!(run_fsm(&fsm, &"ba".to_string()), StateType::EndState); // 'b' leads to z2, then 'a' stays in z2 - assert_eq!(run_fsm(&fsm, &"bb".to_string()), StateType::ErrorState); + assert_eq!(run_fsm(&fsm, &"ab".to_string()), StateType::Terminal); + assert_eq!(run_fsm(&fsm, &"ba".to_string()), StateType::Terminal); // 'b' leads to z2, then 'a' stays in z2 + assert_eq!(run_fsm(&fsm, &"bb".to_string()), StateType::Error); assert_eq!(run_fsm(&fsm, &"aaa".to_string()), StateType::None); - assert_eq!(run_fsm(&fsm, &"aab".to_string()), StateType::EndState); - assert_eq!(run_fsm(&fsm, &"abb".to_string()), StateType::ErrorState); - assert_eq!(run_fsm(&fsm, &"abc".to_string()), StateType::ErrorState); // 'c' is not defined, should go to default z3 - assert_eq!(run_fsm(&fsm, &"c".to_string()), StateType::ErrorState); - assert_eq!(run_fsm(&fsm, &"cab".to_string()), StateType::ErrorState); - assert_eq!(run_fsm(&fsm, &"aabc".to_string()), StateType::ErrorState); - assert_eq!(run_fsm(&fsm, &"bca".to_string()), StateType::ErrorState); - assert_eq!(run_fsm(&fsm, &"aaaaab".to_string()), StateType::EndState); - assert_eq!(run_fsm(&fsm, &"aaaaac".to_string()), StateType::ErrorState); - assert_eq!(run_fsm(&fsm, &"bbbb".to_string()), StateType::ErrorState); + assert_eq!(run_fsm(&fsm, &"aab".to_string()), StateType::Terminal); + assert_eq!(run_fsm(&fsm, &"abb".to_string()), StateType::Error); + assert_eq!(run_fsm(&fsm, &"abc".to_string()), StateType::Error); // 'c' is not defined, should go to default z3 + assert_eq!(run_fsm(&fsm, &"c".to_string()), StateType::Error); + assert_eq!(run_fsm(&fsm, &"cab".to_string()), StateType::Error); + assert_eq!(run_fsm(&fsm, &"aabc".to_string()), StateType::Error); + assert_eq!(run_fsm(&fsm, &"bca".to_string()), StateType::Error); + assert_eq!(run_fsm(&fsm, &"aaaaab".to_string()), StateType::Terminal); + assert_eq!(run_fsm(&fsm, &"aaaaac".to_string()), StateType::Error); + assert_eq!(run_fsm(&fsm, &"bbbb".to_string()), StateType::Error); }