diff --git a/.cargo/config.toml b/.cargo/config.toml index 4d1a8e5e1..a3e7fd63e 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -74,3 +74,4 @@ test-lint-fix = [ "--fix", "--allow-dirty", ] +generate-fsm-diagrams = ["run", "--bin", "generate-fsm-diagrams", "--package", "temporal-sdk-core"] \ No newline at end of file diff --git a/core/Cargo.toml b/core/Cargo.toml index c9ceccda7..3f68c27a4 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -213,3 +213,7 @@ required-features = [ [lints] workspace = true + +[[bin]] +name = "generate-fsm-diagrams" +path = "src/bin/generate_fsm_diagrams.rs" diff --git a/core/src/bin/generate_fsm_diagrams.rs b/core/src/bin/generate_fsm_diagrams.rs new file mode 100644 index 000000000..d60e6a73e --- /dev/null +++ b/core/src/bin/generate_fsm_diagrams.rs @@ -0,0 +1,262 @@ +use std::env; +use std::fs; +use std::io; +use std::path::Path; +use std::process::{Command, Stdio}; + +fn main() { + // Require d2 upfront; no HTML or D2 fallback when missing + if !d2_available() { + eprintln!( + "Error: d2 is not installed. SVG generation and index.html will not be produced.\nInstall d2: https://d2lang.com/tour/install" + ); + std::process::exit(1); + } + + // Resolve core crate dir and diagram dirs + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()); + let diagram_dir = Path::new(&manifest_dir).join("src/worker/workflow/machines/diagrams"); + let svg_dir = diagram_dir.join("svg"); + + // Ensure directory exists and clean prior outputs + let _ = fs::create_dir_all(&diagram_dir); + let _ = clean_dir(&diagram_dir, &["d2", "svg"]); + let _ = fs::remove_dir_all(&svg_dir); + + // Trigger proc-macro generation via cargo check with env set on the child + let _ = Command::new("cargo") + .arg("check") + .arg("--quiet") + .env("TEMPORAL_GENERATE_FSM_DIAGRAMS", "1") + .env("TEMPORAL_FSM_DIAGRAM_DIR", diagram_dir.as_os_str()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + // If no d2 files were generated (cargo no-op), force a rebuild via touching a source file + if count_ext(&diagram_dir, "d2") == 0 { + // Try touching core/src/lib.rs, else touch Cargo.toml + let lib_rs = Path::new(&manifest_dir).join("src/lib.rs"); + if !touch_file(&lib_rs) { + let ct = Path::new(&manifest_dir).join("Cargo.toml"); + let _ = touch_file(&ct); + } + let _ = Command::new("cargo") + .arg("check") + .arg("--quiet") + .env("TEMPORAL_GENERATE_FSM_DIAGRAMS", "1") + .env("TEMPORAL_FSM_DIAGRAM_DIR", diagram_dir.as_os_str()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } + + // Validate .d2 presence + if count_ext(&diagram_dir, "d2") == 0 { + eprintln!( + "Error: No .d2 diagrams were generated. Ensure state machines compile and try again." + ); + std::process::exit(2); + } + + // Render SVGs + let _ = fs::create_dir_all(&svg_dir); + if let Ok(entries) = fs::read_dir(&diagram_dir) { + for entry in entries.flatten() { + let p = entry.path(); + if p.extension().and_then(|e| e.to_str()) == Some("d2") { + let stem = p.file_stem().unwrap(); + let out = svg_dir.join(format!("{}.svg", stem.to_string_lossy())); + let _ = Command::new("d2") + .arg(&p) + .arg(&out) + .arg("--theme") + .arg("200") + .arg("--layout") + .arg("elk") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } + } + } + + // Create HTML only if we have SVGs + if count_ext(&svg_dir, "svg") == 0 { + eprintln!("Error: No SVGs generated (d2 run produced none)."); + std::process::exit(3); + } + let _ = write_index_html(&diagram_dir, true); + println!("{}", svg_dir.display()); +} + +fn d2_available() -> bool { + Command::new("d2") + .arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn clean_dir(dir: &Path, exts: &[&str]) -> io::Result<()> { + if !dir.exists() { + return Ok(()); + } + for entry in fs::read_dir(dir)? { + if let Ok(entry) = entry { + let p = entry.path(); + if p.is_file() { + if let Some(ext) = p.extension().and_then(|e| e.to_str()) { + if exts.iter().any(|x| *x == ext) { + let _ = fs::remove_file(p); + } + } + } + } + } + Ok(()) +} + +fn write_index_html(diagram_dir: &Path, _use_svg: bool) -> io::Result<()> { + #[derive(Clone)] + struct Item { + stem: String, + id: String, + label: String, + } + let mut items: Vec = vec![]; + let svg_dir = diagram_dir.join("svg"); + if let Ok(entries) = fs::read_dir(&svg_dir) { + for entry in entries.flatten() { + let p = entry.path(); + if p.extension().and_then(|e| e.to_str()) == Some("svg") { + if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) { + let stem_s = stem.to_string(); + let label = + display_label_from_d2(diagram_dir, stem).unwrap_or_else(|| stem_s.clone()); + let id = label.clone(); + items.push(Item { + stem: stem_s, + id, + label, + }); + } + } + } + } + items.sort_by(|a, b| a.label.cmp(&b.label)); + + let mut html = String::new(); + html.push_str("\n"); + html.push_str("Temporal SDK Core FSM Diagrams\n"); + html.push_str("\n"); + + // Sidebar + html.push_str("\n"); + + // Content + html.push_str("
\n"); + for it in &items { + html.push_str(&format!( + "
\n

{}

\n", + it.id, it.label + )); + let src = format!("svg/{}.svg", it.stem); + html.push_str(&format!( + "\"{}\"/\n", + src, it.label + )); + html.push_str("
\n"); + } + html.push_str("
\n"); + + fs::write(diagram_dir.join("index.html"), html) +} + +fn display_label_from_d2(diagram_dir: &Path, stem: &str) -> Option { + let d2 = diagram_dir.join(format!("{}.d2", stem)); + let content = fs::read_to_string(d2).ok()?; + for line in content.lines().take(5) { + if let Some(name) = line + .strip_prefix('#') + .and_then(|rest| rest.trim().strip_suffix(" State Machine")) + { + let mut s = to_snake(name.trim()); + if let Some(stripped) = s.strip_suffix("_machine") { + s = stripped.to_string(); + } + return Some(s); + } + } + None +} + +fn to_snake(s: &str) -> String { + let mut out = String::new(); + let mut prev_lower = false; + for ch in s.chars() { + if ch.is_ascii_uppercase() { + if prev_lower && !out.is_empty() { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + prev_lower = false; + } else { + let is_sep = ch == ' ' || ch == '-' || ch == '/'; + if is_sep { + if !out.ends_with('_') && !out.is_empty() { + out.push('_'); + } + prev_lower = false; + } else { + out.push(ch); + prev_lower = ch.is_ascii_lowercase() || ch.is_ascii_digit(); + } + } + } + while out.contains("__") { + out = out.replace("__", "_"); + } + out.trim_matches('_').to_string() +} + +fn count_ext(dir: &Path, ext: &str) -> usize { + let mut c = 0usize; + if let Ok(entries) = fs::read_dir(dir) { + for e in entries.flatten() { + let p = e.path(); + if p.extension().and_then(|e| e.to_str()) == Some(ext) { + c += 1; + } + } + } + c +} + +fn touch_file(p: &Path) -> bool { + if !p.exists() { + return false; + } + match fs::read(p) { + Ok(bytes) => fs::write(p, bytes).is_ok(), + Err(_) => false, + } +} diff --git a/core/src/worker/workflow/machines/mod.rs b/core/src/worker/workflow/machines/mod.rs index c00a5400d..00da3f419 100644 --- a/core/src/worker/workflow/machines/mod.rs +++ b/core/src/worker/workflow/machines/mod.rs @@ -20,9 +20,6 @@ mod update_state_machine; mod upsert_search_attributes_state_machine; mod workflow_task_state_machine; -#[cfg(test)] -mod transition_coverage; - pub(crate) use workflow_machines::{MachinesWFTResponseContent, WorkflowMachines}; use crate::{telemetry::VecDisplayer, worker::workflow::WFMachinesError}; @@ -53,9 +50,6 @@ use upsert_search_attributes_state_machine::UpsertSearchAttributesMachine; use workflow_machines::MachineResponse; use workflow_task_state_machine::WorkflowTaskMachine; -#[cfg(test)] -use transition_coverage::add_coverage; - #[enum_dispatch::enum_dispatch] #[allow(clippy::enum_variant_names, clippy::large_enum_variant)] enum Machines { @@ -259,22 +253,7 @@ where &mut self, event: Self::Event, ) -> Result, MachineError> { - #[cfg(test)] - let from_state = self.state().to_string(); - #[cfg(test)] - let converted_event_str = event.to_string(); - - let res = StateMachine::on_event(self, event); - if res.is_ok() { - #[cfg(test)] - add_coverage( - self.name().to_owned(), - from_state, - self.state().to_string(), - converted_event_str, - ); - } - res + StateMachine::on_event(self, event) } } diff --git a/core/src/worker/workflow/machines/transition_coverage.rs b/core/src/worker/workflow/machines/transition_coverage.rs deleted file mode 100644 index 3550385b0..000000000 --- a/core/src/worker/workflow/machines/transition_coverage.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! Look, I'm not happy about the code in here, and you shouldn't be either. This is all an awful, -//! dirty hack to work around the fact that there's no such thing as "run this after all unit tests" -//! in stable Rust. Don't do the things in here. They're bad. This is test only code, and should -//! never ever be removed from behind `#[cfg(test)]` compilation. - -use dashmap::{DashMap, DashSet, mapref::entry::Entry}; -use std::{ - path::PathBuf, - sync::{ - LazyLock, Mutex, - mpsc::{SyncSender, sync_channel}, - }, - thread::JoinHandle, - time::Duration, -}; - -// During test we want to know about which transitions we've covered in state machines -static COVERED_TRANSITIONS: LazyLock>> = - LazyLock::new(DashMap::new); -static COVERAGE_SENDER: LazyLock> = - LazyLock::new(spawn_save_coverage_at_end); -static THREAD_HANDLE: LazyLock>>> = LazyLock::new(|| Mutex::new(None)); - -#[derive(Eq, PartialEq, Hash, Debug)] -struct CoveredTransition { - from_state: String, - to_state: String, - event: String, -} - -pub(super) fn add_coverage( - machine_name: String, - from_state: String, - to_state: String, - event: String, -) { - let ct = CoveredTransition { - from_state, - to_state, - event, - }; - let _ = COVERAGE_SENDER.send((machine_name, ct)); -} - -fn spawn_save_coverage_at_end() -> SyncSender<(String, CoveredTransition)> { - let (tx, rx) = sync_channel(1000); - let handle = std::thread::spawn(move || { - // Assume that, if the entire program hasn't run a state machine transition in the - // last second that we are probably done running all the tests. This is to avoid - // needing to instrument every single test. - while let Ok((machine_name, ct)) = rx.recv_timeout(Duration::from_secs(1)) { - match COVERED_TRANSITIONS.entry(machine_name) { - Entry::Occupied(o) => { - o.get().insert(ct); - } - Entry::Vacant(v) => { - v.insert({ - let ds = DashSet::new(); - ds.insert(ct); - ds - }); - } - } - } - }); - *THREAD_HANDLE.lock().unwrap() = Some(handle); - tx -} - -#[cfg(test)] -mod machine_coverage_report { - use super::*; - use crate::worker::workflow::machines::{ - activity_state_machine::ActivityMachine, - cancel_external_state_machine::CancelExternalMachine, - cancel_workflow_state_machine::CancelWorkflowMachine, - child_workflow_state_machine::ChildWorkflowMachine, - complete_workflow_state_machine::CompleteWorkflowMachine, - continue_as_new_workflow_state_machine::ContinueAsNewWorkflowMachine, - fail_workflow_state_machine::FailWorkflowMachine, - local_activity_state_machine::LocalActivityMachine, - modify_workflow_properties_state_machine::ModifyWorkflowPropertiesMachine, - patch_state_machine::PatchMachine, signal_external_state_machine::SignalExternalMachine, - timer_state_machine::TimerMachine, update_state_machine::UpdateMachine, - upsert_search_attributes_state_machine::UpsertSearchAttributesMachine, - workflow_task_state_machine::WorkflowTaskMachine, - }; - use rustfsm::StateMachine; - use std::{fs::File, io::Write}; - - // This "test" needs to exist so that we have a way to join the spawned thread. Otherwise - // it'll just get abandoned. - #[test] - // Use `cargo test -- --include-ignored` to run this. We don't want to bother with it by default - // because it takes a minimum of a second. - #[ignore] - fn reporter() { - // Make sure thread handle exists - let _ = &*COVERAGE_SENDER; - // Join it - THREAD_HANDLE - .lock() - .unwrap() - .take() - .unwrap() - .join() - .unwrap(); - - // Gather visualizations for all machines - let mut activity = ActivityMachine::visualizer().to_owned(); - let mut timer = TimerMachine::visualizer().to_owned(); - let mut child_wf = ChildWorkflowMachine::visualizer().to_owned(); - let mut complete_wf = CompleteWorkflowMachine::visualizer().to_owned(); - let mut wf_task = WorkflowTaskMachine::visualizer().to_owned(); - let mut fail_wf = FailWorkflowMachine::visualizer().to_owned(); - let mut cont_as_new = ContinueAsNewWorkflowMachine::visualizer().to_owned(); - let mut cancel_wf = CancelWorkflowMachine::visualizer().to_owned(); - let mut version = PatchMachine::visualizer().to_owned(); - let mut signal_ext = SignalExternalMachine::visualizer().to_owned(); - let mut cancel_ext = CancelExternalMachine::visualizer().to_owned(); - let mut la_mach = LocalActivityMachine::visualizer().to_owned(); - let mut upsert_search_attr = UpsertSearchAttributesMachine::visualizer().to_owned(); - let mut modify_wf_props = ModifyWorkflowPropertiesMachine::visualizer().to_owned(); - let mut update = UpdateMachine::visualizer().to_owned(); - - // This isn't at all efficient but doesn't need to be. - // Replace transitions in the vizzes with green color if they are covered. - for item in COVERED_TRANSITIONS.iter() { - let (machine, coverage) = item.pair(); - match machine.as_ref() { - m @ "ActivityMachine" => cover_transitions(m, &mut activity, coverage), - m @ "TimerMachine" => cover_transitions(m, &mut timer, coverage), - m @ "ChildWorkflowMachine" => cover_transitions(m, &mut child_wf, coverage), - m @ "CompleteWorkflowMachine" => cover_transitions(m, &mut complete_wf, coverage), - m @ "WorkflowTaskMachine" => cover_transitions(m, &mut wf_task, coverage), - m @ "FailWorkflowMachine" => cover_transitions(m, &mut fail_wf, coverage), - m @ "ContinueAsNewWorkflowMachine" => { - cover_transitions(m, &mut cont_as_new, coverage); - } - m @ "CancelWorkflowMachine" => cover_transitions(m, &mut cancel_wf, coverage), - m @ "PatchMachine" => cover_transitions(m, &mut version, coverage), - m @ "SignalExternalMachine" => cover_transitions(m, &mut signal_ext, coverage), - m @ "CancelExternalMachine" => cover_transitions(m, &mut cancel_ext, coverage), - m @ "LocalActivityMachine" => cover_transitions(m, &mut la_mach, coverage), - m @ "UpsertSearchAttributesMachine" => { - cover_transitions(m, &mut upsert_search_attr, coverage) - } - m @ "ModifyWorkflowPropertiesMachine" => { - cover_transitions(m, &mut modify_wf_props, coverage) - } - m @ "UpdateMachine" => cover_transitions(m, &mut update, coverage), - m => panic!("Unknown machine {m}"), - } - } - } - - fn cover_transitions(machine: &str, viz: &mut String, cov: &DashSet) { - for trans in cov.iter() { - let find_line = format!( - "{} --> {}: {}", - trans.from_state, trans.to_state, trans.event - ); - if let Some(start) = viz.find(&find_line) { - let new_line = format!( - "{} -[#blue]-> {}: {}", - trans.from_state, trans.to_state, trans.event - ); - viz.replace_range(start..start + find_line.len(), &new_line); - } - } - - // Dump the updated viz to a file - let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - d.push("machine_coverage"); - std::fs::create_dir_all(&d).unwrap(); - d.push(format!("{machine}_Coverage.puml")); - let mut file = File::create(d).unwrap(); - file.write_all(viz.as_bytes()).unwrap(); - } -} diff --git a/fsm/rustfsm_procmacro/Cargo.toml b/fsm/rustfsm_procmacro/Cargo.toml index ea49df00c..2f2477e38 100644 --- a/fsm/rustfsm_procmacro/Cargo.toml +++ b/fsm/rustfsm_procmacro/Cargo.toml @@ -19,6 +19,7 @@ proc-macro2 = "1.0" syn = { version = "2.0", features = ["default", "extra-traits"] } quote = "1.0" rustfsm_trait = { version = "0.1", path = "../rustfsm_trait" } +temporal-sdk-core-protos = { path = "../../sdk-core-protos" } [dev-dependencies] trybuild = { version = "1.0", features = ["diff"] } diff --git a/fsm/rustfsm_procmacro/src/lib.rs b/fsm/rustfsm_procmacro/src/lib.rs index e6d34017f..4c7d1510d 100644 --- a/fsm/rustfsm_procmacro/src/lib.rs +++ b/fsm/rustfsm_procmacro/src/lib.rs @@ -1,6 +1,8 @@ use proc_macro::TokenStream; use quote::{quote, quote_spanned}; use std::collections::{HashMap, HashSet, hash_map::Entry}; +use std::env; +use std::fs; use syn::{ Error, Fields, Ident, Token, Type, Variant, Visibility, parenthesized, parse::{Parse, ParseStream, Result}, @@ -167,6 +169,43 @@ use syn::{ #[proc_macro] pub fn fsm(input: TokenStream) -> TokenStream { let def: StateMachineDefinition = parse_macro_input!(input as StateMachineDefinition); + + // Generate d2 diagram if requested via environment variable + // This is compile-time only and doesn't affect runtime behavior + #[cfg(not(doc))] // Don't generate diagrams during doc builds + { + if env::var("TEMPORAL_GENERATE_FSM_DIAGRAMS").is_ok() { + let diagram_content = def.generate_d2_diagram(); + + // Determine output directory + let out_dir = match env::var("TEMPORAL_FSM_DIAGRAM_DIR") { + Ok(p) => p, + Err(_) => { + let base = env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()); + format!("{}/src/worker/workflow/machines/diagrams", base) + } + }; + + // Create directory if it doesn't exist + if let Err(e) = fs::create_dir_all(&out_dir) { + eprintln!( + "Warning: Failed to create diagram directory {}: {}", + out_dir, e + ); + } else { + // Generate filename from state machine name + let filename = format!("{}/{}.d2", out_dir, def.name.to_string().to_lowercase()); + + // Write the diagram file + if let Err(e) = fs::write(&filename, diagram_content) { + eprintln!("Warning: Failed to write diagram file {}: {}", filename, e); + } else { + eprintln!("Generated FSM diagram: {}", filename); + } + } + } + } + def.codegen() } @@ -192,6 +231,136 @@ impl StateMachineDefinition { // If no transitions go from this state, it's a final state. !self.transitions.iter().any(|t| t.from == *state) } + + fn generate_d2_diagram(&self) -> String { + let mut output = String::new(); + + // Header + output.push_str(&format!("# {} State Machine\n\n", self.name)); + + // Collect all states + let mut states = HashSet::new(); + for transition in &self.transitions { + states.insert(&transition.from); + for to_state in &transition.to { + states.insert(to_state); + } + } + + // Sort states for deterministic output + let mut sorted_states: Vec<_> = states.iter().collect(); + sorted_states.sort_by_key(|s| s.to_string()); + + // Generate states + output.push_str("# States\n"); + for state in sorted_states { + let state_str = state.to_string(); + output.push_str(&format!("{}: {{\n", state_str)); + output.push_str(" shape: rectangle\n"); + output.push_str("}\n\n"); + } + + // Generate transitions + output.push_str("# Transitions\n"); + for transition in &self.transitions { + let event_name = &transition.event.ident; + let event_str = event_name.to_string(); + + // Classify event type for coloring using known history event names (from proto) + let is_history = is_proto_history_event(&event_str); + let color = if is_history { + "#1565c0" // History events (server) + } else if event_str.starts_with("Command") || event_str.starts_with("Protocol") { + "#2e7d32" // Command acks/queueing + } else { + "#6a1b9a" // Internal triggers + }; + + for to_state in &transition.to { + let label = if let Some(handler) = &transition.handler { + format!("{}\\n({})", event_str, handler) + } else { + event_str.clone() + }; + + output.push_str(&format!("{} -> {}: {{\n", transition.from, to_state)); + output.push_str(&format!(" label: \"{}\"\n", label)); + output.push_str(&format!(" style.stroke: \"{}\"\n", color)); + output.push_str(" style.stroke-width: 2\n"); + output.push_str("}\n\n"); + } + } + + // Legend arrows + output.push_str("\n# Legend\n"); + output.push_str("legend_history_start: \"\" {style.opacity: 0}\n"); + output.push_str("legend_history_end: \"\" {style.opacity: 0}\n"); + output.push_str("legend_history_start -> legend_history_end: {\n"); + output.push_str(" label: \"History Event\"\n"); + output.push_str(" style.stroke: \"#1565c0\"\n"); + output.push_str(" style.stroke-width: 2\n"); + output.push_str("}\n\n"); + + output.push_str("legend_command_start: \"\" {style.opacity: 0}\n"); + output.push_str("legend_command_end: \"\" {style.opacity: 0}\n"); + output.push_str("legend_command_start -> legend_command_end: {\n"); + output.push_str(" label: \"Outgoing\\nCommand\"\n"); + output.push_str(" style.stroke: \"#2e7d32\"\n"); + output.push_str(" style.stroke-width: 2\n"); + output.push_str("}\n\n"); + + output.push_str("legend_internal_start: \"\" {style.opacity: 0}\n"); + output.push_str("legend_internal_end: \"\" {style.opacity: 0}\n"); + output.push_str("legend_internal_start -> legend_internal_end: {\n"); + output.push_str(" label: \"Activation Completion\\nCommand /\\nInternal Trigger\"\n"); + output.push_str(" style.stroke: \"#6a1b9a\"\n"); + output.push_str(" style.stroke-width: 2\n"); + output.push_str("}\n"); + + output + } +} + +fn is_proto_history_event(name: &str) -> bool { + let screaming = to_screaming_snake(name); + let candidate = format!("EVENT_TYPE_{}", screaming); + for candidate in [ + candidate.clone(), + candidate.replace("CANCELLED", "CANCELED"), + ] { + if temporal_sdk_core_protos::temporal::api::enums::v1::EventType::from_str_name(&candidate) + .is_some() + { + return true; + } + } + false +} + +fn to_screaming_snake(name: &str) -> String { + let mut out = String::with_capacity(name.len() * 2); + let mut prev_lower_or_digit = false; + for ch in name.chars() { + if ch.is_ascii_uppercase() { + if prev_lower_or_digit && !out.is_empty() { + out.push('_'); + } + out.push(ch); + prev_lower_or_digit = false; + } else if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_uppercase()); + prev_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit(); + } else { + if !out.ends_with('_') { + out.push('_'); + } + prev_lower_or_digit = false; + } + } + while out.contains("__") { + out = out.replace("__", "_"); + } + out.trim_matches('_').to_string() } impl Parse for StateMachineDefinition { @@ -557,8 +726,6 @@ impl StateMachineDefinition { } )).collect(); - let viz_str = self.visualize(); - let trait_impl = quote! { impl ::rustfsm::StateMachine for #name { type Error = #err_type; @@ -600,9 +767,7 @@ impl StateMachineDefinition { Self { shared_state: shared, state: Some(state) } } - fn visualizer() -> &'static str { - #viz_str - } + fn visualizer() -> &'static str { "" } } }; @@ -629,26 +794,6 @@ impl StateMachineDefinition { }) .collect() } - - fn visualize(&self) -> String { - let transitions: Vec = self - .transitions - .iter() - .flat_map(|t| { - t.to.iter() - .map(move |d| format!("{} --> {}: {}", t.from, d, t.event.ident)) - }) - // Add all final state transitions - .chain( - self.all_states() - .iter() - .filter(|s| self.is_final_state(s)) - .map(|s| format!("{s} --> [*]")), - ) - .collect(); - let transitions = transitions.join("\n"); - format!("@startuml\n{transitions}\n@enduml") - } } /// Merge transition's dest state lists for those with the same from state & handler diff --git a/fsm/rustfsm_trait/src/lib.rs b/fsm/rustfsm_trait/src/lib.rs index 5e58d72bc..f92f47641 100644 --- a/fsm/rustfsm_trait/src/lib.rs +++ b/fsm/rustfsm_trait/src/lib.rs @@ -1,6 +1,6 @@ use std::{ error::Error, - fmt::{Debug, Display, Formatter}, + fmt::{Display, Formatter}, }; /// This trait defines a state machine (more formally, a [finite state @@ -46,7 +46,6 @@ pub trait StateMachine: Sized { } /// The error returned by [StateMachine]s when handling events -#[derive(Debug)] pub enum MachineError { /// An undefined transition was attempted InvalidTransition, @@ -60,11 +59,20 @@ impl From for MachineError { } } +impl std::fmt::Debug for MachineError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MachineError::InvalidTransition => f.write_str("Invalid transition"), + MachineError::Underlying(e) => std::fmt::Debug::fmt(e, f), + } + } +} + impl Display for MachineError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { MachineError::InvalidTransition => f.write_str("Invalid transition"), - MachineError::Underlying(e) => Display::fmt(&e, f), + MachineError::Underlying(e) => Display::fmt(e, f), } } }