metis_core/domain/
configuration.rs

1use crate::domain::documents::types::DocumentType;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5/// Flight level configuration defining which levels are enabled
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub struct FlightLevelConfig {
8    /// Whether strategy level is enabled
9    pub strategies_enabled: bool,
10    /// Whether initiative level is enabled
11    pub initiatives_enabled: bool,
12}
13
14impl FlightLevelConfig {
15    /// Create a new configuration
16    pub fn new(
17        strategies_enabled: bool,
18        initiatives_enabled: bool,
19    ) -> Result<Self, ConfigurationError> {
20        // Validation: If initiatives are disabled, strategies must also be disabled
21        if !initiatives_enabled && strategies_enabled {
22            return Err(ConfigurationError::InvalidConfiguration(
23                "Cannot enable strategies without initiatives - this would create a gap in the hierarchy".to_string()
24            ));
25        }
26
27        Ok(Self {
28            strategies_enabled,
29            initiatives_enabled,
30        })
31    }
32
33    /// Full flight levels: Vision → Strategy → Initiative → Task
34    pub fn full() -> Self {
35        Self {
36            strategies_enabled: true,
37            initiatives_enabled: true,
38        }
39    }
40
41    /// Streamlined flight levels: Vision → Initiative → Task
42    pub fn streamlined() -> Self {
43        Self {
44            strategies_enabled: false,
45            initiatives_enabled: true,
46        }
47    }
48
49    /// Direct flight levels: Vision → Task
50    pub fn direct() -> Self {
51        Self {
52            strategies_enabled: false,
53            initiatives_enabled: false,
54        }
55    }
56
57    /// Check if a document type is allowed in this configuration
58    pub fn is_document_type_allowed(&self, doc_type: DocumentType) -> bool {
59        match doc_type {
60            DocumentType::Vision | DocumentType::Adr => true, // Always allowed
61            DocumentType::Task => true,                       // Always allowed
62            DocumentType::Strategy => self.strategies_enabled,
63            DocumentType::Initiative => self.initiatives_enabled,
64        }
65    }
66
67    /// Get the parent document type for a given document type in this configuration
68    pub fn get_parent_type(&self, doc_type: DocumentType) -> Option<DocumentType> {
69        match doc_type {
70            DocumentType::Vision | DocumentType::Adr => None, // Top level documents
71            DocumentType::Strategy => Some(DocumentType::Vision),
72            DocumentType::Initiative => {
73                if self.strategies_enabled {
74                    Some(DocumentType::Strategy)
75                } else {
76                    Some(DocumentType::Vision)
77                }
78            }
79            DocumentType::Task => {
80                if self.initiatives_enabled {
81                    Some(DocumentType::Initiative)
82                } else {
83                    Some(DocumentType::Vision)
84                }
85            }
86        }
87    }
88
89    /// Get the configuration name/preset
90    pub fn preset_name(&self) -> &'static str {
91        match (self.strategies_enabled, self.initiatives_enabled) {
92            (true, true) => "full",
93            (false, true) => "streamlined",
94            (false, false) => "direct",
95            (true, false) => "invalid", // This shouldn't happen due to validation
96        }
97    }
98
99    /// Get enabled document types in hierarchical order
100    pub fn enabled_document_types(&self) -> Vec<DocumentType> {
101        let mut types = vec![DocumentType::Vision];
102
103        if self.strategies_enabled {
104            types.push(DocumentType::Strategy);
105        }
106
107        if self.initiatives_enabled {
108            types.push(DocumentType::Initiative);
109        }
110
111        types.push(DocumentType::Task);
112        types.push(DocumentType::Adr); // ADRs are always available
113
114        types
115    }
116
117    /// Get the hierarchy display string
118    pub fn hierarchy_display(&self) -> String {
119        let mut hierarchy = vec!["Vision"];
120
121        if self.strategies_enabled {
122            hierarchy.push("Strategy");
123        }
124
125        if self.initiatives_enabled {
126            hierarchy.push("Initiative");
127        }
128
129        hierarchy.push("Task");
130
131        hierarchy.join(" → ")
132    }
133}
134
135impl Default for FlightLevelConfig {
136    fn default() -> Self {
137        Self::full()
138    }
139}
140
141impl fmt::Display for FlightLevelConfig {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        write!(f, "{}", self.preset_name())
144    }
145}
146
147/// Configuration validation errors
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum ConfigurationError {
150    InvalidConfiguration(String),
151    SerializationError(String),
152    InvalidValue(String),
153    MissingConfiguration(String),
154}
155
156impl fmt::Display for ConfigurationError {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        match self {
159            ConfigurationError::InvalidConfiguration(msg) => {
160                write!(f, "Invalid configuration: {}", msg)
161            }
162            ConfigurationError::SerializationError(msg) => {
163                write!(f, "Serialization error: {}", msg)
164            }
165            ConfigurationError::InvalidValue(msg) => write!(f, "Invalid value: {}", msg),
166            ConfigurationError::MissingConfiguration(msg) => {
167                write!(f, "Missing configuration: {}", msg)
168            }
169        }
170    }
171}
172
173impl std::error::Error for ConfigurationError {}
174
175/// Configuration file structure that persists to .metis/config.toml
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct ConfigFile {
178    pub project: ProjectConfig,
179    pub flight_levels: FlightLevelConfig,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183pub struct ProjectConfig {
184    pub prefix: String,
185}
186
187impl ConfigFile {
188    /// Create a new configuration with defaults
189    pub fn new(prefix: String, flight_levels: FlightLevelConfig) -> Result<Self, ConfigurationError> {
190        // Validate prefix format: 2-8 uppercase letters
191        if !prefix.chars().all(|c| c.is_ascii_uppercase()) || prefix.len() < 2 || prefix.len() > 8 {
192            return Err(ConfigurationError::InvalidValue(
193                "Project prefix must be 2-8 uppercase letters".to_string(),
194            ));
195        }
196
197        Ok(Self {
198            project: ProjectConfig { prefix },
199            flight_levels,
200        })
201    }
202
203    /// Load configuration from a TOML file
204    pub fn load<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ConfigurationError> {
205        let content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
206            ConfigurationError::SerializationError(format!("Failed to read config file: {}", e))
207        })?;
208
209        toml::from_str(&content).map_err(|e| {
210            ConfigurationError::SerializationError(format!("Failed to parse TOML: {}", e))
211        })
212    }
213
214    /// Save configuration to a TOML file
215    pub fn save<P: AsRef<std::path::Path>>(&self, path: P) -> Result<(), ConfigurationError> {
216        let content = toml::to_string_pretty(self).map_err(|e| {
217            ConfigurationError::SerializationError(format!("Failed to serialize config: {}", e))
218        })?;
219
220        std::fs::write(path.as_ref(), content).map_err(|e| {
221            ConfigurationError::SerializationError(format!("Failed to write config file: {}", e))
222        })?;
223
224        Ok(())
225    }
226
227    /// Create default configuration with given prefix
228    pub fn default_with_prefix(prefix: String) -> Result<Self, ConfigurationError> {
229        Self::new(prefix, FlightLevelConfig::streamlined())
230    }
231
232    /// Get the project prefix
233    pub fn prefix(&self) -> &str {
234        &self.project.prefix
235    }
236
237    /// Get the flight level configuration
238    pub fn flight_levels(&self) -> &FlightLevelConfig {
239        &self.flight_levels
240    }
241}
242
243impl Default for ConfigFile {
244    fn default() -> Self {
245        Self {
246            project: ProjectConfig {
247                prefix: "PROJ".to_string(),
248            },
249            flight_levels: FlightLevelConfig::streamlined(),
250        }
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_preset_configurations() {
260        let full = FlightLevelConfig::full();
261        assert!(full.strategies_enabled);
262        assert!(full.initiatives_enabled);
263        assert_eq!(full.preset_name(), "full");
264
265        let streamlined = FlightLevelConfig::streamlined();
266        assert!(!streamlined.strategies_enabled);
267        assert!(streamlined.initiatives_enabled);
268        assert_eq!(streamlined.preset_name(), "streamlined");
269
270        let direct = FlightLevelConfig::direct();
271        assert!(!direct.strategies_enabled);
272        assert!(!direct.initiatives_enabled);
273        assert_eq!(direct.preset_name(), "direct");
274    }
275
276    #[test]
277    fn test_configuration_validation() {
278        // Valid configurations
279        assert!(FlightLevelConfig::new(true, true).is_ok());
280        assert!(FlightLevelConfig::new(false, true).is_ok());
281        assert!(FlightLevelConfig::new(false, false).is_ok());
282
283        // Invalid configuration: strategies enabled but initiatives disabled
284        assert!(FlightLevelConfig::new(true, false).is_err());
285    }
286
287    #[test]
288    fn test_document_type_allowed() {
289        let full = FlightLevelConfig::full();
290        assert!(full.is_document_type_allowed(DocumentType::Vision));
291        assert!(full.is_document_type_allowed(DocumentType::Strategy));
292        assert!(full.is_document_type_allowed(DocumentType::Initiative));
293        assert!(full.is_document_type_allowed(DocumentType::Task));
294        assert!(full.is_document_type_allowed(DocumentType::Adr));
295
296        let streamlined = FlightLevelConfig::streamlined();
297        assert!(streamlined.is_document_type_allowed(DocumentType::Vision));
298        assert!(!streamlined.is_document_type_allowed(DocumentType::Strategy));
299        assert!(streamlined.is_document_type_allowed(DocumentType::Initiative));
300        assert!(streamlined.is_document_type_allowed(DocumentType::Task));
301        assert!(streamlined.is_document_type_allowed(DocumentType::Adr));
302
303        let direct = FlightLevelConfig::direct();
304        assert!(direct.is_document_type_allowed(DocumentType::Vision));
305        assert!(!direct.is_document_type_allowed(DocumentType::Strategy));
306        assert!(!direct.is_document_type_allowed(DocumentType::Initiative));
307        assert!(direct.is_document_type_allowed(DocumentType::Task));
308        assert!(direct.is_document_type_allowed(DocumentType::Adr));
309    }
310
311    #[test]
312    fn test_parent_type_resolution() {
313        let full = FlightLevelConfig::full();
314        assert_eq!(full.get_parent_type(DocumentType::Vision), None);
315        assert_eq!(
316            full.get_parent_type(DocumentType::Strategy),
317            Some(DocumentType::Vision)
318        );
319        assert_eq!(
320            full.get_parent_type(DocumentType::Initiative),
321            Some(DocumentType::Strategy)
322        );
323        assert_eq!(
324            full.get_parent_type(DocumentType::Task),
325            Some(DocumentType::Initiative)
326        );
327        assert_eq!(full.get_parent_type(DocumentType::Adr), None);
328
329        let streamlined = FlightLevelConfig::streamlined();
330        assert_eq!(
331            streamlined.get_parent_type(DocumentType::Initiative),
332            Some(DocumentType::Vision)
333        );
334        assert_eq!(
335            streamlined.get_parent_type(DocumentType::Task),
336            Some(DocumentType::Initiative)
337        );
338
339        let direct = FlightLevelConfig::direct();
340        assert_eq!(
341            direct.get_parent_type(DocumentType::Task),
342            Some(DocumentType::Vision)
343        );
344    }
345
346    #[test]
347    fn test_enabled_document_types() {
348        let full = FlightLevelConfig::full();
349        let full_types = full.enabled_document_types();
350        assert_eq!(
351            full_types,
352            vec![
353                DocumentType::Vision,
354                DocumentType::Strategy,
355                DocumentType::Initiative,
356                DocumentType::Task,
357                DocumentType::Adr
358            ]
359        );
360
361        let streamlined = FlightLevelConfig::streamlined();
362        let streamlined_types = streamlined.enabled_document_types();
363        assert_eq!(
364            streamlined_types,
365            vec![
366                DocumentType::Vision,
367                DocumentType::Initiative,
368                DocumentType::Task,
369                DocumentType::Adr
370            ]
371        );
372
373        let direct = FlightLevelConfig::direct();
374        let direct_types = direct.enabled_document_types();
375        assert_eq!(
376            direct_types,
377            vec![DocumentType::Vision, DocumentType::Task, DocumentType::Adr]
378        );
379    }
380
381    #[test]
382    fn test_hierarchy_display() {
383        assert_eq!(
384            FlightLevelConfig::full().hierarchy_display(),
385            "Vision → Strategy → Initiative → Task"
386        );
387        assert_eq!(
388            FlightLevelConfig::streamlined().hierarchy_display(),
389            "Vision → Initiative → Task"
390        );
391        assert_eq!(
392            FlightLevelConfig::direct().hierarchy_display(),
393            "Vision → Task"
394        );
395    }
396
397    #[test]
398    fn test_serialization() {
399        let config = FlightLevelConfig::streamlined();
400        let json = serde_json::to_string(&config).unwrap();
401        let deserialized: FlightLevelConfig = serde_json::from_str(&json).unwrap();
402        assert_eq!(config, deserialized);
403    }
404
405    #[test]
406    fn test_config_file_creation() {
407        let config = ConfigFile::new("TEST".to_string(), FlightLevelConfig::streamlined()).unwrap();
408        assert_eq!(config.prefix(), "TEST");
409        assert_eq!(config.flight_levels(), &FlightLevelConfig::streamlined());
410    }
411
412    #[test]
413    fn test_config_file_validation() {
414        // Valid prefixes
415        assert!(ConfigFile::new("AB".to_string(), FlightLevelConfig::streamlined()).is_ok());
416        assert!(ConfigFile::new("ABCDEFGH".to_string(), FlightLevelConfig::streamlined()).is_ok());
417        assert!(ConfigFile::new("METIS".to_string(), FlightLevelConfig::streamlined()).is_ok());
418
419        // Invalid prefixes
420        assert!(ConfigFile::new("A".to_string(), FlightLevelConfig::streamlined()).is_err()); // Too short
421        assert!(ConfigFile::new("ABCDEFGHI".to_string(), FlightLevelConfig::streamlined()).is_err()); // Too long
422        assert!(ConfigFile::new("ab".to_string(), FlightLevelConfig::streamlined()).is_err()); // Lowercase
423        assert!(ConfigFile::new("A1".to_string(), FlightLevelConfig::streamlined()).is_err()); // Contains number
424        assert!(ConfigFile::new("A-B".to_string(), FlightLevelConfig::streamlined()).is_err()); // Contains hyphen
425    }
426
427    #[test]
428    fn test_config_file_save_and_load() {
429        use tempfile::NamedTempFile;
430
431        let original_config = ConfigFile::new("METIS".to_string(), FlightLevelConfig::full()).unwrap();
432
433        // Create a temporary file
434        let temp_file = NamedTempFile::new().unwrap();
435        let temp_path = temp_file.path().to_path_buf();
436
437        // Save configuration
438        original_config.save(&temp_path).unwrap();
439
440        // Load configuration
441        let loaded_config = ConfigFile::load(&temp_path).unwrap();
442
443        // Verify they match
444        assert_eq!(original_config, loaded_config);
445        assert_eq!(loaded_config.prefix(), "METIS");
446        assert_eq!(loaded_config.flight_levels(), &FlightLevelConfig::full());
447    }
448
449    #[test]
450    fn test_config_file_toml_format() {
451        use tempfile::NamedTempFile;
452
453        let config = ConfigFile::new("METIS".to_string(), FlightLevelConfig::streamlined()).unwrap();
454
455        // Create a temporary file
456        let temp_file = NamedTempFile::new().unwrap();
457        let temp_path = temp_file.path();
458
459        // Save configuration
460        config.save(temp_path).unwrap();
461
462        // Read raw TOML content
463        let content = std::fs::read_to_string(temp_path).unwrap();
464
465        // Verify TOML structure
466        assert!(content.contains("[project]"));
467        assert!(content.contains("prefix = \"METIS\""));
468        assert!(content.contains("[flight_levels]"));
469        assert!(content.contains("strategies_enabled = false"));
470        assert!(content.contains("initiatives_enabled = true"));
471    }
472
473    #[test]
474    fn test_config_file_default() {
475        let config = ConfigFile::default();
476        assert_eq!(config.prefix(), "PROJ");
477        assert_eq!(config.flight_levels(), &FlightLevelConfig::streamlined());
478    }
479
480    #[test]
481    fn test_config_file_default_with_prefix() {
482        let config = ConfigFile::default_with_prefix("CUSTOM".to_string()).unwrap();
483        assert_eq!(config.prefix(), "CUSTOM");
484        assert_eq!(config.flight_levels(), &FlightLevelConfig::streamlined());
485    }
486}