Skip to main content

llmtxt_core/
lifecycle.rs

1//! Document lifecycle state machine.
2//!
3//! Defines the allowed states for collaborative documents and validates
4//! transitions between them. All functions are pure.
5
6#[cfg(feature = "wasm")]
7use wasm_bindgen::prelude::*;
8
9/// Lifecycle state of a collaborative document.
10///
11/// Matches the TypeScript `DocumentState` type exactly.
12#[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    /// Parse a state from its string representation (case-insensitive).
23    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    /// Return the canonical uppercase string name.
34    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    /// Return the allowed transition targets from this state.
44    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/// Check whether a state transition is allowed.
61#[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/// Check whether a document state allows content modifications.
67///
68/// Only DRAFT and REVIEW states accept new versions/patches.
69#[cfg_attr(feature = "wasm", wasm_bindgen)]
70pub fn is_editable(state: DocumentState) -> bool {
71    matches!(state, DocumentState::Draft | DocumentState::Review)
72}
73
74/// Check whether a document state is terminal (no further transitions).
75#[cfg_attr(feature = "wasm", wasm_bindgen)]
76pub fn is_terminal(state: DocumentState) -> bool {
77    state.allowed_targets().is_empty()
78}
79
80// ── WASM string helpers ────────────────────────────────────────
81
82/// Parse a state string and check if the transition is valid.
83/// Accepts uppercase state names ("DRAFT", "REVIEW", etc.).
84/// Returns false for unrecognized state names.
85#[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/// Parse a state string and check if it's editable.
97/// Returns false for unrecognized state names.
98#[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/// Parse a state string and check if it's terminal.
104/// Returns false for unrecognized state names.
105#[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/// Validate a proposed transition and return a JSON result.
111///
112/// Returns a JSON object with `valid`, `reason`, and `allowedTargets` fields.
113/// Matches the TypeScript `TransitionResult` interface.
114#[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")); // case-insensitive
276        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}