metis_core/
context.rs

1//! Document context and related types for template rendering
2
3use crate::{DocumentType, MetisError, Result};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7/// Context for document creation containing all template variables
8#[derive(Debug, Clone, Serialize)]
9pub struct DocumentContext {
10    // Core fields for all documents
11    pub title: String,
12    pub slug: String,
13    pub created_at: DateTime<Utc>,
14    pub updated_at: DateTime<Utc>,
15    pub parent_title: Option<String>,
16    pub blocked_by: Vec<String>,
17
18    // Document-type specific fields
19    pub risk_level: Option<RiskLevel>,        // Strategy
20    pub stakeholders: Vec<String>,            // Strategy, Vision
21    pub technical_lead: Option<String>,       // Initiative
22    pub complexity: Option<Complexity>,       // Initiative
23    pub decision_maker: Option<String>,       // ADR
24    pub decision_date: Option<DateTime<Utc>>, // ADR
25    pub number: Option<u32>,                  // ADR
26}
27
28/// Risk level for strategies
29#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum RiskLevel {
32    Low,
33    Medium,
34    High,
35    Critical,
36}
37
38/// Complexity level for initiatives
39#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum Complexity {
42    S,  // Small
43    M,  // Medium
44    L,  // Large
45    XL, // Extra Large
46}
47
48impl DocumentContext {
49    /// Create a new DocumentContext with required fields
50    pub fn new(title: String) -> Self {
51        let slug = Self::title_to_slug(&title);
52        let now = Utc::now();
53
54        Self {
55            title,
56            slug,
57            created_at: now,
58            updated_at: now,
59            parent_title: None,
60            blocked_by: Vec::new(),
61            risk_level: None,
62            stakeholders: Vec::new(),
63            technical_lead: None,
64            complexity: None,
65            decision_maker: None,
66            decision_date: None,
67            number: None,
68        }
69    }
70
71    /// Validate context for a specific document type
72    pub fn validate_for_type(&self, doc_type: &DocumentType) -> Result<()> {
73        match doc_type {
74            DocumentType::Strategy => {
75                if self.risk_level.is_none() {
76                    return Err(MetisError::MissingRequiredField {
77                        field: "risk_level".to_string(),
78                    });
79                }
80            }
81            DocumentType::Initiative => {
82                if self.complexity.is_none() {
83                    return Err(MetisError::MissingRequiredField {
84                        field: "complexity".to_string(),
85                    });
86                }
87            }
88            DocumentType::Adr => {
89                if self.decision_maker.is_none() {
90                    return Err(MetisError::MissingRequiredField {
91                        field: "decision_maker".to_string(),
92                    });
93                }
94                if self.number.is_none() {
95                    return Err(MetisError::MissingRequiredField {
96                        field: "number".to_string(),
97                    });
98                }
99            }
100            DocumentType::Vision | DocumentType::Task => {
101                // No additional required fields
102            }
103        }
104        Ok(())
105    }
106
107    /// Convert title to URL-friendly slug
108    pub fn title_to_slug(title: &str) -> String {
109        title
110            .to_lowercase()
111            .chars()
112            .map(|c| if c.is_alphanumeric() { c } else { '-' })
113            .collect::<String>()
114            .split('-')
115            .filter(|s| !s.is_empty())
116            .collect::<Vec<_>>()
117            .join("-")
118    }
119
120    /// Builder pattern methods for setting optional fields
121    pub fn with_parent(mut self, parent_title: String) -> Self {
122        self.parent_title = Some(parent_title);
123        self
124    }
125
126    pub fn with_blocked_by(mut self, blocked_by: Vec<String>) -> Self {
127        self.blocked_by = blocked_by;
128        self
129    }
130
131    pub fn with_risk_level(mut self, risk_level: RiskLevel) -> Self {
132        self.risk_level = Some(risk_level);
133        self
134    }
135
136    pub fn with_stakeholders(mut self, stakeholders: Vec<String>) -> Self {
137        self.stakeholders = stakeholders;
138        self
139    }
140
141    pub fn with_technical_lead(mut self, technical_lead: String) -> Self {
142        self.technical_lead = Some(technical_lead);
143        self
144    }
145
146    pub fn with_complexity(mut self, complexity: Complexity) -> Self {
147        self.complexity = Some(complexity);
148        self
149    }
150
151    pub fn with_decision_maker(mut self, decision_maker: String) -> Self {
152        self.decision_maker = Some(decision_maker);
153        self
154    }
155
156    pub fn with_decision_date(mut self, decision_date: DateTime<Utc>) -> Self {
157        self.decision_date = Some(decision_date);
158        self
159    }
160
161    pub fn with_number(mut self, number: u32) -> Self {
162        self.number = Some(number);
163        self
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use chrono::Utc;
171
172    #[test]
173    fn test_title_to_slug() {
174        assert_eq!(
175            DocumentContext::title_to_slug("Core Document Management Library"),
176            "core-document-management-library"
177        );
178        assert_eq!(
179            DocumentContext::title_to_slug("ADR-001: Document Format"),
180            "adr-001-document-format"
181        );
182        assert_eq!(
183            DocumentContext::title_to_slug("Storage & Indexing System"),
184            "storage-indexing-system"
185        );
186    }
187
188    #[test]
189    fn test_new_context() {
190        let ctx = DocumentContext::new("Test Document".to_string());
191        assert_eq!(ctx.title, "Test Document");
192        assert_eq!(ctx.slug, "test-document");
193        assert!(ctx.created_at <= Utc::now());
194        assert!(ctx.updated_at <= Utc::now());
195    }
196
197    #[test]
198    fn test_strategy_validation() {
199        let ctx = DocumentContext::new("Test Strategy".to_string());
200
201        // Should fail without risk_level
202        assert!(ctx.validate_for_type(&DocumentType::Strategy).is_err());
203
204        // Should pass with risk_level
205        let ctx_with_risk = ctx.with_risk_level(RiskLevel::Medium);
206        assert!(ctx_with_risk
207            .validate_for_type(&DocumentType::Strategy)
208            .is_ok());
209    }
210
211    #[test]
212    fn test_initiative_validation() {
213        let ctx = DocumentContext::new("Test Initiative".to_string());
214
215        // Should fail without complexity
216        assert!(ctx.validate_for_type(&DocumentType::Initiative).is_err());
217
218        // Should pass with complexity
219        let ctx_with_complexity = ctx.with_complexity(Complexity::M);
220        assert!(ctx_with_complexity
221            .validate_for_type(&DocumentType::Initiative)
222            .is_ok());
223    }
224
225    #[test]
226    fn test_adr_validation() {
227        let ctx = DocumentContext::new("Test ADR".to_string());
228
229        // Should fail without decision_maker and number
230        assert!(ctx.validate_for_type(&DocumentType::Adr).is_err());
231
232        // Should pass with required fields
233        let ctx_complete = ctx
234            .with_decision_maker("Engineering Team".to_string())
235            .with_number(1);
236        assert!(ctx_complete.validate_for_type(&DocumentType::Adr).is_ok());
237    }
238
239    #[test]
240    fn test_vision_and_task_validation() {
241        let ctx = DocumentContext::new("Test Document".to_string());
242
243        // Vision and Task have no additional requirements
244        assert!(ctx.validate_for_type(&DocumentType::Vision).is_ok());
245        assert!(ctx.validate_for_type(&DocumentType::Task).is_ok());
246    }
247
248    #[test]
249    fn test_builder_pattern() {
250        let ctx = DocumentContext::new("Test Document".to_string())
251            .with_parent("Parent Document".to_string())
252            .with_blocked_by(vec!["Blocker 1".to_string(), "Blocker 2".to_string()])
253            .with_stakeholders(vec!["Alice".to_string(), "Bob".to_string()]);
254
255        assert_eq!(ctx.parent_title, Some("Parent Document".to_string()));
256        assert_eq!(ctx.blocked_by.len(), 2);
257        assert_eq!(ctx.stakeholders.len(), 2);
258    }
259}