Skip to main content

cdx_core/
state.rs

1//! Document state machine.
2//!
3//! Codex documents progress through a defined lifecycle:
4//!
5//! ```text
6//! draft → review → frozen → published
7//! ```
8//!
9//! Each state has specific characteristics and requirements.
10
11use serde::{Deserialize, Serialize};
12
13/// Document lifecycle state.
14///
15/// Documents progress through states that determine what operations are allowed
16/// and what integrity guarantees are enforced.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::Display)]
18#[serde(rename_all = "lowercase")]
19#[strum(serialize_all = "lowercase")]
20pub enum DocumentState {
21    /// Active editing state.
22    ///
23    /// - Content can be freely modified
24    /// - Document ID may be "pending"
25    /// - No signatures required
26    Draft,
27
28    /// Review/approval state.
29    ///
30    /// - Content can still be modified
31    /// - Document ID should be computed
32    /// - Signatures optional
33    Review,
34
35    /// Immutable state.
36    ///
37    /// - Content cannot be modified
38    /// - Document ID must be computed and valid
39    /// - At least one signature required
40    /// - Lineage required (parent required only if forked)
41    Frozen,
42
43    /// Distribution state.
44    ///
45    /// - Same immutability as frozen
46    /// - Indicates document is ready for public distribution
47    /// - At least one signature required
48    /// - Lineage required (parent required only if forked)
49    Published,
50}
51
52impl DocumentState {
53    /// Returns whether the document content can be modified in this state.
54    #[must_use]
55    pub const fn is_editable(&self) -> bool {
56        matches!(self, Self::Draft | Self::Review)
57    }
58
59    /// Returns whether the document is immutable in this state.
60    #[must_use]
61    pub const fn is_immutable(&self) -> bool {
62        matches!(self, Self::Frozen | Self::Published)
63    }
64
65    /// Returns whether signatures are required in this state.
66    #[must_use]
67    pub const fn requires_signature(&self) -> bool {
68        matches!(self, Self::Frozen | Self::Published)
69    }
70
71    /// Returns whether lineage *may* be required in this state.
72    ///
73    /// Per spec, lineage is mandatory for forked documents (those with a parent)
74    /// in Frozen/Published states. Root documents can transition to these states
75    /// without lineage. This method indicates the state where lineage requirements
76    /// apply; actual enforcement depends on whether the document is forked.
77    #[must_use]
78    pub const fn requires_lineage(&self) -> bool {
79        matches!(self, Self::Frozen | Self::Published)
80    }
81
82    /// Returns whether the document ID must be computed (not "pending").
83    #[must_use]
84    pub const fn requires_computed_id(&self) -> bool {
85        matches!(self, Self::Review | Self::Frozen | Self::Published)
86    }
87
88    /// Returns whether a precise layout is required in this state.
89    ///
90    /// Frozen and published documents require at least one precise layout
91    /// to ensure exact visual reproduction as part of the immutable record.
92    #[must_use]
93    pub const fn requires_precise_layout(&self) -> bool {
94        matches!(self, Self::Frozen | Self::Published)
95    }
96
97    /// Check if transitioning to the target state is valid.
98    #[must_use]
99    pub const fn can_transition_to(&self, target: Self) -> bool {
100        use DocumentState::{Draft, Frozen, Published, Review};
101        matches!(
102            (self, target),
103            (Draft, Review) | (Review, Frozen | Draft) | (Frozen, Published)
104        )
105    }
106
107    /// Get all valid target states from the current state.
108    #[must_use]
109    pub const fn valid_transitions(&self) -> &'static [DocumentState] {
110        use DocumentState::{Draft, Frozen, Published, Review};
111        match self {
112            Draft => &[Review],
113            Review => &[Draft, Frozen],
114            Frozen => &[Published],
115            Published => &[],
116        }
117    }
118}
119
120impl std::str::FromStr for DocumentState {
121    type Err = crate::Error;
122
123    fn from_str(s: &str) -> Result<Self, Self::Err> {
124        match s.to_lowercase().as_str() {
125            "draft" => Ok(Self::Draft),
126            "review" => Ok(Self::Review),
127            "frozen" => Ok(Self::Frozen),
128            "published" => Ok(Self::Published),
129            _ => Err(crate::Error::InvalidManifest {
130                reason: format!("invalid state: {s}"),
131            }),
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_editability() {
142        assert!(DocumentState::Draft.is_editable());
143        assert!(DocumentState::Review.is_editable());
144        assert!(!DocumentState::Frozen.is_editable());
145        assert!(!DocumentState::Published.is_editable());
146    }
147
148    #[test]
149    fn test_immutability() {
150        assert!(!DocumentState::Draft.is_immutable());
151        assert!(!DocumentState::Review.is_immutable());
152        assert!(DocumentState::Frozen.is_immutable());
153        assert!(DocumentState::Published.is_immutable());
154    }
155
156    #[test]
157    fn test_valid_transitions() {
158        use DocumentState::{Draft, Frozen, Published, Review};
159
160        // Draft can go to Review
161        assert!(Draft.can_transition_to(Review));
162        assert!(!Draft.can_transition_to(Frozen));
163        assert!(!Draft.can_transition_to(Published));
164
165        // Review can go to Draft or Frozen
166        assert!(Review.can_transition_to(Draft));
167        assert!(Review.can_transition_to(Frozen));
168        assert!(!Review.can_transition_to(Published));
169
170        // Frozen can go to Published
171        assert!(!Frozen.can_transition_to(Draft));
172        assert!(!Frozen.can_transition_to(Review));
173        assert!(Frozen.can_transition_to(Published));
174
175        // Published is terminal
176        assert!(!Published.can_transition_to(Draft));
177        assert!(!Published.can_transition_to(Review));
178        assert!(!Published.can_transition_to(Frozen));
179    }
180
181    #[test]
182    fn test_serialization() {
183        let state = DocumentState::Frozen;
184        let json = serde_json::to_string(&state).unwrap();
185        assert_eq!(json, "\"frozen\"");
186
187        let parsed: DocumentState = serde_json::from_str("\"review\"").unwrap();
188        assert_eq!(parsed, DocumentState::Review);
189    }
190
191    #[test]
192    fn test_display() {
193        assert_eq!(DocumentState::Draft.to_string(), "draft");
194        assert_eq!(DocumentState::Published.to_string(), "published");
195    }
196
197    #[test]
198    fn test_from_str() {
199        assert_eq!(
200            "draft".parse::<DocumentState>().unwrap(),
201            DocumentState::Draft
202        );
203        assert_eq!(
204            "FROZEN".parse::<DocumentState>().unwrap(),
205            DocumentState::Frozen
206        );
207        assert!("invalid".parse::<DocumentState>().is_err());
208    }
209
210    #[test]
211    fn test_precise_layout_requirement() {
212        assert!(!DocumentState::Draft.requires_precise_layout());
213        assert!(!DocumentState::Review.requires_precise_layout());
214        assert!(DocumentState::Frozen.requires_precise_layout());
215        assert!(DocumentState::Published.requires_precise_layout());
216    }
217}