1use serde::{Deserialize, Serialize};
2
3use super::{
4 ChartSpec, Dash, DiagramOrientation, DiagramRole, DiagramSpacing, flow::validate_role,
5 is_false, sequence::is_identifier, validate_text,
6};
7use crate::error::{ChartError, ChartWarning};
8
9pub(crate) const MAX_STATES: usize = 40;
12pub(crate) const MAX_TRANSITIONS: usize = 80;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase", deny_unknown_fields)]
17pub struct StateSpec {
18 pub states: Vec<StateNodeSpec>,
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
20 pub transitions: Vec<TransitionSpec>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub initial: Option<String>,
24 #[serde(default, skip_serializing_if = "Vec::is_empty")]
27 pub main_path: Vec<String>,
28 #[serde(default, skip_serializing_if = "DiagramOrientation::is_auto")]
29 pub orientation: DiagramOrientation,
30 #[serde(default, skip_serializing_if = "DiagramSpacing::is_normal")]
31 pub spacing: DiagramSpacing,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct StateNodeSpec {
37 pub id: String,
38 pub label: String,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub sublabel: Option<String>,
41 #[serde(default, skip_serializing_if = "StateKind::is_state")]
42 pub kind: StateKind,
43 #[serde(default, rename = "final", skip_serializing_if = "is_false")]
45 pub is_final: bool,
46 #[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
48 pub parent: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub role: Option<DiagramRole>,
51}
52
53#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "lowercase")]
56pub enum StateKind {
57 #[default]
59 State,
60 Choice,
62 Composite,
64}
65
66impl StateKind {
67 #[allow(clippy::trivially_copy_pass_by_ref)]
69 const fn is_state(&self) -> bool {
70 matches!(self, Self::State)
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase", deny_unknown_fields)]
77pub struct TransitionSpec {
78 pub from: String,
79 pub to: String,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub event: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub guard: Option<String>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub action: Option<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub dash: Option<Dash>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub role: Option<DiagramRole>,
93}
94
95impl TransitionSpec {
96 pub(crate) fn label(&self) -> Option<String> {
99 let mut parts = Vec::new();
100 if let Some(event) = &self.event {
101 parts.push(event.clone());
102 }
103 if let Some(guard) = &self.guard {
104 parts.push(format!("[{guard}]"));
105 }
106 if let Some(action) = &self.action {
107 parts.push(format!("/ {action}"));
108 }
109 (!parts.is_empty()).then(|| parts.join(" "))
110 }
111}
112
113pub(crate) const MAX_COMPOSITE_DEPTH: usize = 4;
115
116impl StateSpec {
117 pub(crate) fn is_composite(&self, index: usize) -> bool {
119 self.states[index].kind == StateKind::Composite
120 }
121
122 pub(crate) fn composites_around(&self, index: usize) -> Option<Vec<usize>> {
125 let mut chain = Vec::new();
126 let mut at = index;
127 while let Some(parent) = self.states[at].parent.as_deref() {
128 at = self.state(parent)?;
129 if chain.contains(&at) || at == index {
130 return None;
131 }
132 chain.push(at);
133 }
134 chain.reverse();
135 Some(chain)
136 }
137
138 pub(crate) fn state(&self, id: &str) -> Option<usize> {
140 self.states.iter().position(|state| state.id == id)
141 }
142
143 pub(crate) fn on_main_path(&self, index: usize) -> bool {
145 let transition = &self.transitions[index];
146 self.main_path
147 .windows(2)
148 .any(|pair| pair[0] == transition.from && pair[1] == transition.to)
149 }
150}
151
152impl ChartSpec {
153 pub(super) fn validate_state(&self) -> Result<Vec<ChartWarning>, ChartError> {
157 self.reject_map_options("a state diagram", "state.states")?;
158 let Some(machine) = &self.state else {
159 return Err(ChartError::new(
160 "missing_state",
161 "/state",
162 "a state diagram requires a state block",
163 ));
164 };
165 machine.validate_states()?;
166 machine.validate_transitions()?;
167 machine.validate_initial_and_path()?;
168 let mut warnings = Vec::new();
169 if let Some(initial) = &machine.initial {
170 for (index, state) in machine.states.iter().enumerate() {
171 if machine.is_composite(index) {
172 continue;
173 }
174 let reached = machine
175 .transitions
176 .iter()
177 .any(|transition| transition.to == state.id && transition.from != state.id);
178 if !reached && &state.id != initial {
179 warnings.push(ChartWarning::new(
180 "unreachable_state",
181 format!("/state/states/{index}"),
182 format!(
183 "no transition leads to \"{}\" and it is not the initial state",
184 state.id
185 ),
186 ));
187 }
188 }
189 }
190 Ok(warnings)
191 }
192}
193
194impl StateSpec {
195 fn validate_states(&self) -> Result<(), ChartError> {
196 if self.states.is_empty() {
197 return Err(ChartError::new(
198 "empty_data",
199 "/state/states",
200 "provide at least one state",
201 ));
202 }
203 if self.states.len() > MAX_STATES {
204 return Err(ChartError::new(
205 "too_many_states",
206 "/state/states",
207 format!("at most {MAX_STATES} states are supported"),
208 ));
209 }
210 for (index, state) in self.states.iter().enumerate() {
211 let path = format!("/state/states/{index}");
212 if !is_identifier(&state.id) {
213 return Err(ChartError::new(
214 "invalid_id",
215 format!("{path}/id"),
216 "use 1–64 ASCII letters, digits, hyphens, or underscores, starting with a letter",
217 ));
218 }
219 if self.states[..index]
220 .iter()
221 .any(|earlier| earlier.id == state.id)
222 {
223 return Err(ChartError::new(
224 "duplicate_id",
225 format!("{path}/id"),
226 format!(
227 "the id \"{}\" is already taken by an earlier state",
228 state.id
229 ),
230 ));
231 }
232 validate_text(&state.label, &format!("{path}/label"), 60)?;
233 if let Some(sublabel) = &state.sublabel {
234 validate_text(sublabel, &format!("{path}/sublabel"), 60)?;
235 }
236 if state.is_final && state.kind == StateKind::Choice {
237 return Err(ChartError::new(
238 "option_not_supported",
239 format!("{path}/final"),
240 "a choice is passed through at once and cannot be final",
241 ));
242 }
243 }
244 self.validate_composites()
245 }
246
247 fn validate_composites(&self) -> Result<(), ChartError> {
250 for (index, state) in self.states.iter().enumerate() {
251 let path = format!("/state/states/{index}");
252 if let Some(parent) = &state.parent {
253 let Some(outer) = self.state(parent) else {
254 return Err(ChartError::new(
255 "unknown_state",
256 format!("{path}/in"),
257 format!("no state has the id \"{parent}\""),
258 ));
259 };
260 if !self.is_composite(outer) {
261 return Err(ChartError::new(
262 "not_composite",
263 format!("{path}/in"),
264 format!(
265 "\"{parent}\" is no composite state; give it \"kind\": \"composite\""
266 ),
267 ));
268 }
269 }
270 let Some(chain) = self.composites_around(index) else {
271 return Err(ChartError::new(
272 "state_cycle",
273 format!("{path}/in"),
274 "composite states lie in each other in a circle",
275 ));
276 };
277 if chain.len() >= MAX_COMPOSITE_DEPTH && self.is_composite(index) {
278 return Err(ChartError::new(
279 "states_too_deep",
280 format!("{path}/in"),
281 format!("composite states nest at most {MAX_COMPOSITE_DEPTH} deep"),
282 ));
283 }
284 if self.is_composite(index) {
285 if state.role.is_some() {
286 return Err(ChartError::new(
287 "option_not_supported",
288 format!("{path}/role"),
289 "a composite state is a frame and takes no role; give the states inside it roles",
290 ));
291 }
292 if state.is_final {
293 return Err(ChartError::new(
294 "option_not_supported",
295 format!("{path}/final"),
296 "a composite state is a frame and cannot be final; make a state inside it final",
297 ));
298 }
299 let holds = (0..self.states.len()).any(|inner| {
300 !self.is_composite(inner)
301 && self
302 .composites_around(inner)
303 .is_some_and(|chain| chain.contains(&index))
304 });
305 if !holds {
306 return Err(ChartError::new(
307 "empty_composite",
308 path,
309 format!("no state lies in \"{}\"", state.id),
310 ));
311 }
312 }
313 }
314 Ok(())
315 }
316
317 fn reject_composite(&self, id: &str, path: String) -> Result<(), ChartError> {
319 match self.state(id) {
320 Some(index) if self.is_composite(index) => Err(ChartError::new(
321 "composite_not_allowed",
322 path,
323 format!("\"{id}\" is a composite state, a frame; name a state inside it"),
324 )),
325 _ => Ok(()),
326 }
327 }
328
329 fn validate_transitions(&self) -> Result<(), ChartError> {
330 if self.transitions.len() > MAX_TRANSITIONS {
331 return Err(ChartError::new(
332 "too_many_transitions",
333 "/state/transitions",
334 format!("at most {MAX_TRANSITIONS} transitions are supported"),
335 ));
336 }
337 for (index, transition) in self.transitions.iter().enumerate() {
338 let path = format!("/state/transitions/{index}");
339 for (field, id) in [("from", &transition.from), ("to", &transition.to)] {
340 if self.state(id).is_none() {
341 return Err(ChartError::new(
342 "unknown_state",
343 format!("{path}/{field}"),
344 format!("no state has the id \"{id}\""),
345 ));
346 }
347 self.reject_composite(id, format!("{path}/{field}"))?;
348 }
349 for (field, text) in [
350 ("event", &transition.event),
351 ("guard", &transition.guard),
352 ("action", &transition.action),
353 ] {
354 if let Some(text) = text {
355 validate_text(text, &format!("{path}/{field}"), 40)?;
356 }
357 }
358 validate_role(transition.role, transition.dash, &path)?;
359 }
360 Ok(())
361 }
362
363 fn validate_initial_and_path(&self) -> Result<(), ChartError> {
364 if let Some(initial) = &self.initial
365 && self.state(initial).is_none()
366 {
367 return Err(ChartError::new(
368 "unknown_state",
369 "/state/initial",
370 format!("no state has the id \"{initial}\""),
371 ));
372 }
373 if let Some(initial) = &self.initial {
374 self.reject_composite(initial, "/state/initial".to_owned())?;
375 }
376 for (index, id) in self.main_path.iter().enumerate() {
377 if self.state(id).is_none() {
378 return Err(ChartError::new(
379 "unknown_state",
380 format!("/state/mainPath/{index}"),
381 format!("no state has the id \"{id}\""),
382 ));
383 }
384 self.reject_composite(id, format!("/state/mainPath/{index}"))?;
385 }
386 for (index, pair) in self.main_path.windows(2).enumerate() {
387 let joined = self
388 .transitions
389 .iter()
390 .any(|transition| transition.from == pair[0] && transition.to == pair[1]);
391 if !joined {
392 return Err(ChartError::new(
393 "main_path_gap",
394 format!("/state/mainPath/{}", index + 1),
395 format!(
396 "no transition leads from \"{}\" to \"{}\"; the main path follows transitions",
397 pair[0], pair[1]
398 ),
399 ));
400 }
401 }
402 Ok(())
403 }
404}