Skip to main content

entrenar/storage/registry/
stage.rs

1//! Model lifecycle stages (Kanban workflow)
2
3use serde::{Deserialize, Serialize};
4
5/// Model lifecycle stages (Kanban workflow)
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
7pub enum ModelStage {
8    /// Not assigned to any stage
9    None,
10    /// In active development
11    Development,
12    /// Being tested/validated
13    Staging,
14    /// Deployed and serving traffic
15    Production,
16    /// Retired from active use
17    Archived,
18}
19
20impl ModelStage {
21    /// Check if transition to target stage is valid
22    pub fn can_transition_to(&self, target: ModelStage) -> bool {
23        match (self, target) {
24            // Any stage can go to Archived
25            (
26                ModelStage::None
27                | ModelStage::Development
28                | ModelStage::Staging
29                | ModelStage::Production
30                | ModelStage::Archived,
31                ModelStage::Archived,
32            ) => true,
33            // None can go to Development
34            (ModelStage::None, ModelStage::Development) => true,
35            // Development can go to Staging
36            (ModelStage::Development, ModelStage::Staging) => true,
37            // Staging can go to Production
38            (ModelStage::Staging, ModelStage::Production) => true,
39            // Production can go back to Staging (rollback)
40            (ModelStage::Production, ModelStage::Staging) => true,
41            // Staging can go back to Development (rejected)
42            (ModelStage::Staging, ModelStage::Development) => true,
43            // Archived can be restored to Development
44            (ModelStage::Archived, ModelStage::Development) => true,
45            // Same stage is a no-op
46            (ModelStage::None, ModelStage::None)
47            | (ModelStage::Development, ModelStage::Development)
48            | (ModelStage::Staging, ModelStage::Staging)
49            | (ModelStage::Production, ModelStage::Production) => true,
50            // Invalid transitions
51            (
52                ModelStage::Development
53                | ModelStage::Staging
54                | ModelStage::Production
55                | ModelStage::Archived,
56                ModelStage::None,
57            )
58            | (ModelStage::Production, ModelStage::Development)
59            | (ModelStage::None | ModelStage::Archived, ModelStage::Staging)
60            | (
61                ModelStage::None | ModelStage::Development | ModelStage::Archived,
62                ModelStage::Production,
63            ) => false,
64        }
65    }
66
67    /// Get display name
68    pub fn as_str(&self) -> &'static str {
69        match self {
70            ModelStage::None => "None",
71            ModelStage::Development => "Development",
72            ModelStage::Staging => "Staging",
73            ModelStage::Production => "Production",
74            ModelStage::Archived => "Archived",
75        }
76    }
77}
78
79impl std::fmt::Display for ModelStage {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        write!(f, "{}", self.as_str())
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn test_stage_none_to_development() {
91        assert!(ModelStage::None.can_transition_to(ModelStage::Development));
92    }
93
94    #[test]
95    fn test_stage_development_to_staging() {
96        assert!(ModelStage::Development.can_transition_to(ModelStage::Staging));
97    }
98
99    #[test]
100    fn test_stage_staging_to_production() {
101        assert!(ModelStage::Staging.can_transition_to(ModelStage::Production));
102    }
103
104    #[test]
105    fn test_stage_production_rollback_to_staging() {
106        assert!(ModelStage::Production.can_transition_to(ModelStage::Staging));
107    }
108
109    #[test]
110    fn test_stage_any_to_archived() {
111        assert!(ModelStage::None.can_transition_to(ModelStage::Archived));
112        assert!(ModelStage::Development.can_transition_to(ModelStage::Archived));
113        assert!(ModelStage::Staging.can_transition_to(ModelStage::Archived));
114        assert!(ModelStage::Production.can_transition_to(ModelStage::Archived));
115    }
116
117    #[test]
118    fn test_stage_invalid_transitions() {
119        assert!(!ModelStage::None.can_transition_to(ModelStage::Production));
120        assert!(!ModelStage::Development.can_transition_to(ModelStage::Production));
121    }
122
123    #[test]
124    fn test_stage_display() {
125        assert_eq!(ModelStage::Production.to_string(), "Production");
126        assert_eq!(ModelStage::Development.as_str(), "Development");
127    }
128
129    #[test]
130    fn test_stage_staging_to_development_rejected() {
131        assert!(ModelStage::Staging.can_transition_to(ModelStage::Development));
132    }
133
134    #[test]
135    fn test_stage_archived_to_development_restore() {
136        assert!(ModelStage::Archived.can_transition_to(ModelStage::Development));
137    }
138
139    #[test]
140    fn test_stage_same_stage_noop() {
141        assert!(ModelStage::Development.can_transition_to(ModelStage::Development));
142        assert!(ModelStage::Staging.can_transition_to(ModelStage::Staging));
143        assert!(ModelStage::Production.can_transition_to(ModelStage::Production));
144        assert!(ModelStage::Archived.can_transition_to(ModelStage::Archived));
145        assert!(ModelStage::None.can_transition_to(ModelStage::None));
146    }
147
148    #[test]
149    fn test_stage_invalid_none_to_staging() {
150        assert!(!ModelStage::None.can_transition_to(ModelStage::Staging));
151    }
152
153    #[test]
154    fn test_stage_invalid_archived_to_staging() {
155        assert!(!ModelStage::Archived.can_transition_to(ModelStage::Staging));
156    }
157
158    #[test]
159    fn test_stage_invalid_archived_to_production() {
160        assert!(!ModelStage::Archived.can_transition_to(ModelStage::Production));
161    }
162
163    #[test]
164    fn test_as_str_all_stages() {
165        assert_eq!(ModelStage::None.as_str(), "None");
166        assert_eq!(ModelStage::Staging.as_str(), "Staging");
167        assert_eq!(ModelStage::Archived.as_str(), "Archived");
168    }
169
170    #[test]
171    fn test_display_all_stages() {
172        assert_eq!(format!("{}", ModelStage::None), "None");
173        assert_eq!(format!("{}", ModelStage::Development), "Development");
174        assert_eq!(format!("{}", ModelStage::Staging), "Staging");
175        assert_eq!(format!("{}", ModelStage::Archived), "Archived");
176    }
177
178    #[test]
179    fn test_stage_serialization() {
180        let stage = ModelStage::Production;
181        let json = serde_json::to_string(&stage).expect("JSON serialization should succeed");
182        assert!(json.contains("Production"));
183    }
184
185    #[test]
186    fn test_stage_deserialization() {
187        let json = "\"Staging\"";
188        let stage: ModelStage =
189            serde_json::from_str(json).expect("JSON deserialization should succeed");
190        assert_eq!(stage, ModelStage::Staging);
191    }
192
193    #[test]
194    fn test_stage_roundtrip() {
195        let stages = [
196            ModelStage::None,
197            ModelStage::Development,
198            ModelStage::Staging,
199            ModelStage::Production,
200            ModelStage::Archived,
201        ];
202        for stage in stages {
203            let json = serde_json::to_string(&stage).expect("JSON serialization should succeed");
204            let deserialized: ModelStage =
205                serde_json::from_str(&json).expect("JSON deserialization should succeed");
206            assert_eq!(stage, deserialized);
207        }
208    }
209
210    #[test]
211    fn test_stage_clone() {
212        let stage = ModelStage::Development;
213        let cloned = stage;
214        assert_eq!(stage, cloned);
215    }
216
217    #[test]
218    fn test_stage_copy() {
219        let stage = ModelStage::Production;
220        let copied = stage;
221        assert_eq!(stage, copied);
222    }
223
224    #[test]
225    fn test_stage_debug() {
226        let stage = ModelStage::Staging;
227        let debug = format!("{stage:?}");
228        assert!(debug.contains("Staging"));
229    }
230
231    #[test]
232    fn test_stage_hash() {
233        use std::collections::HashSet;
234        let mut set = HashSet::new();
235        set.insert(ModelStage::Development);
236        set.insert(ModelStage::Production);
237        assert_eq!(set.len(), 2);
238    }
239}
240
241#[cfg(test)]
242mod property_tests {
243    use super::*;
244    use proptest::prelude::*;
245
246    proptest! {
247        #![proptest_config(ProptestConfig::with_cases(200))]
248
249        #[test]
250        fn prop_stage_self_transition(stage in any::<u8>().prop_map(|n| match n % 5 {
251            0 => ModelStage::None,
252            1 => ModelStage::Development,
253            2 => ModelStage::Staging,
254            3 => ModelStage::Production,
255            _ => ModelStage::Archived,
256        })) {
257            // Self-transition is always valid
258            prop_assert!(stage.can_transition_to(stage));
259        }
260
261        #[test]
262        fn prop_all_stages_can_archive(stage in any::<u8>().prop_map(|n| match n % 5 {
263            0 => ModelStage::None,
264            1 => ModelStage::Development,
265            2 => ModelStage::Staging,
266            3 => ModelStage::Production,
267            _ => ModelStage::Archived,
268        })) {
269            // All stages can transition to Archived
270            prop_assert!(stage.can_transition_to(ModelStage::Archived));
271        }
272    }
273}