1#[cfg(feature = "wasm")]
7use wasm_bindgen::prelude::*;
8
9#[cfg_attr(feature = "wasm", wasm_bindgen)]
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum DocumentState {
15 Draft = 0,
16 Review = 1,
17 Locked = 2,
18 Archived = 3,
19}
20
21impl DocumentState {
22 pub fn from_str_name(s: &str) -> Option<Self> {
24 match s.to_uppercase().as_str() {
25 "DRAFT" => Some(Self::Draft),
26 "REVIEW" => Some(Self::Review),
27 "LOCKED" => Some(Self::Locked),
28 "ARCHIVED" => Some(Self::Archived),
29 _ => None,
30 }
31 }
32
33 pub fn as_str(&self) -> &'static str {
35 match self {
36 Self::Draft => "DRAFT",
37 Self::Review => "REVIEW",
38 Self::Locked => "LOCKED",
39 Self::Archived => "ARCHIVED",
40 }
41 }
42
43 pub fn allowed_targets(&self) -> &'static [DocumentState] {
45 match self {
46 Self::Draft => &[Self::Review, Self::Locked],
47 Self::Review => &[Self::Draft, Self::Locked],
48 Self::Locked => &[Self::Archived],
49 Self::Archived => &[],
50 }
51 }
52}
53
54impl std::fmt::Display for DocumentState {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 f.write_str(self.as_str())
57 }
58}
59
60#[cfg_attr(feature = "wasm", wasm_bindgen)]
62pub fn is_valid_transition(from: DocumentState, to: DocumentState) -> bool {
63 from.allowed_targets().contains(&to)
64}
65
66#[cfg_attr(feature = "wasm", wasm_bindgen)]
70pub fn is_editable(state: DocumentState) -> bool {
71 matches!(state, DocumentState::Draft | DocumentState::Review)
72}
73
74#[cfg_attr(feature = "wasm", wasm_bindgen)]
76pub fn is_terminal(state: DocumentState) -> bool {
77 state.allowed_targets().is_empty()
78}
79
80#[cfg_attr(feature = "wasm", wasm_bindgen)]
86pub fn is_valid_transition_str(from: &str, to: &str) -> bool {
87 match (
88 DocumentState::from_str_name(from),
89 DocumentState::from_str_name(to),
90 ) {
91 (Some(f), Some(t)) => is_valid_transition(f, t),
92 _ => false,
93 }
94}
95
96#[cfg_attr(feature = "wasm", wasm_bindgen)]
99pub fn is_editable_str(state: &str) -> bool {
100 DocumentState::from_str_name(state).is_some_and(is_editable)
101}
102
103#[cfg_attr(feature = "wasm", wasm_bindgen)]
106pub fn is_terminal_str(state: &str) -> bool {
107 DocumentState::from_str_name(state).is_some_and(is_terminal)
108}
109
110#[cfg_attr(feature = "wasm", wasm_bindgen)]
115pub fn validate_transition(from: &str, to: &str) -> String {
116 let from_state = match DocumentState::from_str_name(from) {
117 Some(s) => s,
118 None => {
119 return serde_json::json!({
120 "valid": false,
121 "reason": format!("Unknown state: {from}"),
122 "allowedTargets": []
123 })
124 .to_string();
125 }
126 };
127
128 let to_state = match DocumentState::from_str_name(to) {
129 Some(s) => s,
130 None => {
131 return serde_json::json!({
132 "valid": false,
133 "reason": format!("Unknown state: {to}"),
134 "allowedTargets": from_state.allowed_targets().iter().map(|s| s.as_str()).collect::<Vec<_>>()
135 })
136 .to_string();
137 }
138 };
139
140 let allowed: Vec<&str> = from_state
141 .allowed_targets()
142 .iter()
143 .map(|s| s.as_str())
144 .collect();
145
146 if from_state == to_state {
147 return serde_json::json!({
148 "valid": false,
149 "reason": format!("Document is already in {} state", from_state),
150 "allowedTargets": allowed
151 })
152 .to_string();
153 }
154
155 if !is_valid_transition(from_state, to_state) {
156 let allowed_str = if allowed.is_empty() {
157 "none (terminal state)".to_string()
158 } else {
159 allowed.join(", ")
160 };
161 return serde_json::json!({
162 "valid": false,
163 "reason": format!("Cannot transition from {} to {}. Allowed: {}", from_state, to_state, allowed_str),
164 "allowedTargets": allowed
165 })
166 .to_string();
167 }
168
169 serde_json::json!({
170 "valid": true,
171 "allowedTargets": allowed
172 })
173 .to_string()
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn test_valid_transitions() {
182 assert!(is_valid_transition(
183 DocumentState::Draft,
184 DocumentState::Review
185 ));
186 assert!(is_valid_transition(
187 DocumentState::Draft,
188 DocumentState::Locked
189 ));
190 assert!(is_valid_transition(
191 DocumentState::Review,
192 DocumentState::Draft
193 ));
194 assert!(is_valid_transition(
195 DocumentState::Review,
196 DocumentState::Locked
197 ));
198 assert!(is_valid_transition(
199 DocumentState::Locked,
200 DocumentState::Archived
201 ));
202 }
203
204 #[test]
205 fn test_invalid_transitions() {
206 assert!(!is_valid_transition(
207 DocumentState::Draft,
208 DocumentState::Archived
209 ));
210 assert!(!is_valid_transition(
211 DocumentState::Review,
212 DocumentState::Archived
213 ));
214 assert!(!is_valid_transition(
215 DocumentState::Locked,
216 DocumentState::Draft
217 ));
218 assert!(!is_valid_transition(
219 DocumentState::Locked,
220 DocumentState::Review
221 ));
222 assert!(!is_valid_transition(
223 DocumentState::Archived,
224 DocumentState::Draft
225 ));
226 assert!(!is_valid_transition(
227 DocumentState::Archived,
228 DocumentState::Review
229 ));
230 assert!(!is_valid_transition(
231 DocumentState::Archived,
232 DocumentState::Locked
233 ));
234 }
235
236 #[test]
237 fn test_self_transitions_invalid() {
238 assert!(!is_valid_transition(
239 DocumentState::Draft,
240 DocumentState::Draft
241 ));
242 assert!(!is_valid_transition(
243 DocumentState::Review,
244 DocumentState::Review
245 ));
246 assert!(!is_valid_transition(
247 DocumentState::Locked,
248 DocumentState::Locked
249 ));
250 assert!(!is_valid_transition(
251 DocumentState::Archived,
252 DocumentState::Archived
253 ));
254 }
255
256 #[test]
257 fn test_editable() {
258 assert!(is_editable(DocumentState::Draft));
259 assert!(is_editable(DocumentState::Review));
260 assert!(!is_editable(DocumentState::Locked));
261 assert!(!is_editable(DocumentState::Archived));
262 }
263
264 #[test]
265 fn test_terminal() {
266 assert!(!is_terminal(DocumentState::Draft));
267 assert!(!is_terminal(DocumentState::Review));
268 assert!(!is_terminal(DocumentState::Locked));
269 assert!(is_terminal(DocumentState::Archived));
270 }
271
272 #[test]
273 fn test_string_helpers() {
274 assert!(is_valid_transition_str("DRAFT", "REVIEW"));
275 assert!(is_valid_transition_str("draft", "review")); assert!(!is_valid_transition_str("DRAFT", "ARCHIVED"));
277 assert!(!is_valid_transition_str("DRAFT", "UNKNOWN"));
278 assert!(!is_valid_transition_str("UNKNOWN", "DRAFT"));
279 }
280
281 #[test]
282 fn test_editable_str() {
283 assert!(is_editable_str("DRAFT"));
284 assert!(is_editable_str("REVIEW"));
285 assert!(!is_editable_str("LOCKED"));
286 assert!(!is_editable_str("ARCHIVED"));
287 assert!(!is_editable_str("UNKNOWN"));
288 }
289
290 #[test]
291 fn test_terminal_str() {
292 assert!(!is_terminal_str("DRAFT"));
293 assert!(is_terminal_str("ARCHIVED"));
294 assert!(!is_terminal_str("UNKNOWN"));
295 }
296
297 #[test]
298 fn test_validate_transition_json() {
299 let result: serde_json::Value =
300 serde_json::from_str(&validate_transition("DRAFT", "REVIEW")).unwrap();
301 assert_eq!(result["valid"], true);
302
303 let result: serde_json::Value =
304 serde_json::from_str(&validate_transition("DRAFT", "ARCHIVED")).unwrap();
305 assert_eq!(result["valid"], false);
306 assert!(
307 result["reason"]
308 .as_str()
309 .unwrap()
310 .contains("Cannot transition")
311 );
312
313 let result: serde_json::Value =
314 serde_json::from_str(&validate_transition("DRAFT", "DRAFT")).unwrap();
315 assert_eq!(result["valid"], false);
316 assert!(result["reason"].as_str().unwrap().contains("already in"));
317
318 let result: serde_json::Value =
319 serde_json::from_str(&validate_transition("ARCHIVED", "DRAFT")).unwrap();
320 assert_eq!(result["valid"], false);
321 assert!(result["reason"].as_str().unwrap().contains("terminal"));
322 }
323
324 #[test]
325 fn test_from_str_name() {
326 assert_eq!(
327 DocumentState::from_str_name("DRAFT"),
328 Some(DocumentState::Draft)
329 );
330 assert_eq!(
331 DocumentState::from_str_name("draft"),
332 Some(DocumentState::Draft)
333 );
334 assert_eq!(
335 DocumentState::from_str_name("Draft"),
336 Some(DocumentState::Draft)
337 );
338 assert_eq!(
339 DocumentState::from_str_name("REVIEW"),
340 Some(DocumentState::Review)
341 );
342 assert_eq!(
343 DocumentState::from_str_name("LOCKED"),
344 Some(DocumentState::Locked)
345 );
346 assert_eq!(
347 DocumentState::from_str_name("ARCHIVED"),
348 Some(DocumentState::Archived)
349 );
350 assert_eq!(DocumentState::from_str_name("unknown"), None);
351 }
352}