metis_core/domain/documents/strategy/
mod.rs

1use super::content::DocumentContent;
2use super::helpers::FrontmatterParser;
3use super::metadata::DocumentMetadata;
4use super::traits::{Document, DocumentTemplate, DocumentValidationError};
5use super::types::{DocumentId, DocumentType, Phase, Tag};
6use chrono::Utc;
7use gray_matter;
8use std::path::Path;
9use tera::{Context, Tera};
10
11/// Risk level for strategies
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum RiskLevel {
14    Low,
15    Medium,
16    High,
17    Critical,
18}
19
20impl std::fmt::Display for RiskLevel {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            RiskLevel::Low => write!(f, "low"),
24            RiskLevel::Medium => write!(f, "medium"),
25            RiskLevel::High => write!(f, "high"),
26            RiskLevel::Critical => write!(f, "critical"),
27        }
28    }
29}
30
31impl std::str::FromStr for RiskLevel {
32    type Err = DocumentValidationError;
33
34    fn from_str(s: &str) -> Result<Self, Self::Err> {
35        match s.to_lowercase().as_str() {
36            "low" => Ok(RiskLevel::Low),
37            "medium" => Ok(RiskLevel::Medium),
38            "high" => Ok(RiskLevel::High),
39            "critical" => Ok(RiskLevel::Critical),
40            _ => Err(DocumentValidationError::InvalidContent(format!(
41                "Invalid risk level: {}",
42                s
43            ))),
44        }
45    }
46}
47
48/// A Strategy document defines high-level approaches to achieve vision goals
49#[derive(Debug)]
50pub struct Strategy {
51    core: super::traits::DocumentCore,
52    risk_level: RiskLevel,
53    stakeholders: Vec<String>,
54}
55
56impl Strategy {
57    /// Create a new Strategy document with content rendered from template
58    pub fn new(
59        title: String,
60        parent_id: Option<DocumentId>, // Usually a Vision
61        blocked_by: Vec<DocumentId>,
62        tags: Vec<Tag>,
63        archived: bool,
64        risk_level: RiskLevel,
65        stakeholders: Vec<String>,
66    ) -> Result<Self, DocumentValidationError> {
67        // Create fresh metadata
68        let metadata = DocumentMetadata::new();
69
70        // Render the content template
71        let template_content = include_str!("content.md");
72        let mut tera = Tera::default();
73        tera.add_raw_template("strategy_content", template_content)
74            .map_err(|e| {
75                DocumentValidationError::InvalidContent(format!("Template error: {}", e))
76            })?;
77
78        let mut context = Context::new();
79        context.insert("title", &title);
80
81        let rendered_content = tera.render("strategy_content", &context).map_err(|e| {
82            DocumentValidationError::InvalidContent(format!("Template render error: {}", e))
83        })?;
84
85        let content = DocumentContent::new(&rendered_content);
86
87        Ok(Self {
88            core: super::traits::DocumentCore {
89                title,
90                metadata,
91                content,
92                parent_id,
93                blocked_by,
94                tags,
95                archived,
96            },
97            risk_level,
98            stakeholders,
99        })
100    }
101
102    /// Create a Strategy document from existing data (used when loading from file)
103    #[allow(clippy::too_many_arguments)]
104    pub fn from_parts(
105        title: String,
106        metadata: DocumentMetadata,
107        content: DocumentContent,
108        parent_id: Option<DocumentId>,
109        blocked_by: Vec<DocumentId>,
110        tags: Vec<Tag>,
111        archived: bool,
112        risk_level: RiskLevel,
113        stakeholders: Vec<String>,
114    ) -> Self {
115        Self {
116            core: super::traits::DocumentCore {
117                title,
118                metadata,
119                content,
120                parent_id,
121                blocked_by,
122                tags,
123                archived,
124            },
125            risk_level,
126            stakeholders,
127        }
128    }
129
130    pub fn risk_level(&self) -> RiskLevel {
131        self.risk_level
132    }
133
134    pub fn stakeholders(&self) -> &[String] {
135        &self.stakeholders
136    }
137
138    /// Get the next phase in the Strategy sequence
139    fn next_phase_in_sequence(current: Phase) -> Option<Phase> {
140        use Phase::*;
141        match current {
142            Shaping => Some(Design),
143            Design => Some(Ready),
144            Ready => Some(Active),
145            Active => Some(Completed),
146            Completed => None, // Final phase
147            _ => None,         // Invalid phase for Strategy
148        }
149    }
150
151    /// Update the phase tag in the document's tags
152    fn update_phase_tag(&mut self, new_phase: Phase) {
153        // Remove any existing phase tags
154        self.core.tags.retain(|tag| !matches!(tag, Tag::Phase(_)));
155        // Add the new phase tag
156        self.core.tags.push(Tag::Phase(new_phase));
157        // Update timestamp
158        self.core.metadata.updated_at = Utc::now();
159    }
160
161    /// Create a Strategy document by reading and parsing a file
162    pub async fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, DocumentValidationError> {
163        let raw_content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
164            DocumentValidationError::InvalidContent(format!("Failed to read file: {}", e))
165        })?;
166
167        Self::from_content(&raw_content)
168    }
169
170    /// Create a Strategy document from raw file content string
171    pub fn from_content(raw_content: &str) -> Result<Self, DocumentValidationError> {
172        // Parse frontmatter and content
173        let parsed = gray_matter::Matter::<gray_matter::engine::YAML>::new().parse(raw_content);
174
175        // Extract frontmatter data
176        let frontmatter = parsed.data.ok_or_else(|| {
177            DocumentValidationError::MissingRequiredField("frontmatter".to_string())
178        })?;
179
180        // Parse frontmatter into structured data
181        let fm_map = match frontmatter {
182            gray_matter::Pod::Hash(map) => map,
183            _ => {
184                return Err(DocumentValidationError::InvalidContent(
185                    "Frontmatter must be a hash/map".to_string(),
186                ))
187            }
188        };
189
190        // Extract required fields
191        let title = FrontmatterParser::extract_string(&fm_map, "title")?;
192        let archived = FrontmatterParser::extract_bool(&fm_map, "archived").unwrap_or(false);
193
194        // Parse timestamps
195        let created_at = FrontmatterParser::extract_datetime(&fm_map, "created_at")?;
196        let updated_at = FrontmatterParser::extract_datetime(&fm_map, "updated_at")?;
197        let exit_criteria_met =
198            FrontmatterParser::extract_bool(&fm_map, "exit_criteria_met").unwrap_or(false);
199
200        // Parse tags
201        let tags = FrontmatterParser::extract_tags(&fm_map)?;
202
203        // Verify this is actually a strategy document
204        let level = FrontmatterParser::extract_string(&fm_map, "level")?;
205        if level != "strategy" {
206            return Err(DocumentValidationError::InvalidContent(format!(
207                "Expected level 'strategy', found '{}'",
208                level
209            )));
210        }
211
212        // Extract strategy-specific fields
213        let parent_id = FrontmatterParser::extract_string(&fm_map, "parent")
214            .ok()
215            .map(DocumentId::from);
216        let blocked_by = FrontmatterParser::extract_string_array(&fm_map, "blocked_by")
217            .unwrap_or_default()
218            .into_iter()
219            .map(DocumentId::from)
220            .collect();
221
222        let risk_level = FrontmatterParser::extract_string(&fm_map, "risk_level")
223            .and_then(|s| s.parse::<RiskLevel>())?;
224
225        let stakeholders = FrontmatterParser::extract_string_array(&fm_map, "stakeholders")?;
226
227        // Create metadata and content
228        let metadata =
229            DocumentMetadata::from_frontmatter(created_at, updated_at, exit_criteria_met);
230        let content = DocumentContent::from_markdown(&parsed.content);
231
232        Ok(Self::from_parts(
233            title,
234            metadata,
235            content,
236            parent_id,
237            blocked_by,
238            tags,
239            archived,
240            risk_level,
241            stakeholders,
242        ))
243    }
244
245    /// Write the Strategy document to a file
246    pub async fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), DocumentValidationError> {
247        let content = self.to_content()?;
248        std::fs::write(path.as_ref(), content).map_err(|e| {
249            DocumentValidationError::InvalidContent(format!("Failed to write file: {}", e))
250        })
251    }
252
253    /// Convert the Strategy document to its markdown string representation using templates
254    pub fn to_content(&self) -> Result<String, DocumentValidationError> {
255        let mut tera = Tera::default();
256
257        // Add the frontmatter template to Tera
258        tera.add_raw_template("frontmatter", self.frontmatter_template())
259            .map_err(|e| {
260                DocumentValidationError::InvalidContent(format!("Template error: {}", e))
261            })?;
262
263        // Create context with all document data
264        let mut context = Context::new();
265        context.insert("slug", &self.id().to_string());
266        context.insert("title", self.title());
267        context.insert("created_at", &self.metadata().created_at.to_rfc3339());
268        context.insert("updated_at", &self.metadata().updated_at.to_rfc3339());
269        context.insert("archived", &self.archived().to_string());
270        context.insert(
271            "exit_criteria_met",
272            &self.metadata().exit_criteria_met.to_string(),
273        );
274        context.insert(
275            "parent_id",
276            &self
277                .parent_id()
278                .map(|id| id.to_string())
279                .unwrap_or_default(),
280        );
281
282        // Format blocked_by as YAML list
283        let blocked_by_list: Vec<String> =
284            self.blocked_by().iter().map(|id| id.to_string()).collect();
285        context.insert("blocked_by", &blocked_by_list);
286        context.insert("risk_level", &self.risk_level.to_string());
287        context.insert("stakeholders", &self.stakeholders);
288
289        // Convert tags to strings
290        let tag_strings: Vec<String> = self.tags().iter().map(|tag| tag.to_str()).collect();
291        context.insert("tags", &tag_strings);
292
293        // Render frontmatter
294        let frontmatter = tera.render("frontmatter", &context).map_err(|e| {
295            DocumentValidationError::InvalidContent(format!("Frontmatter render error: {}", e))
296        })?;
297
298        // Use the actual content body
299        let content_body = &self.content().body;
300
301        // Use actual acceptance criteria if present, otherwise empty string
302        let acceptance_criteria = if let Some(ac) = &self.content().acceptance_criteria {
303            format!("\n\n## Acceptance Criteria\n\n{}", ac)
304        } else {
305            String::new()
306        };
307
308        // Combine everything
309        Ok(format!(
310            "---\n{}\n---\n\n{}{}",
311            frontmatter.trim_end(),
312            content_body,
313            acceptance_criteria
314        ))
315    }
316
317    // Helper methods
318}
319
320impl Document for Strategy {
321    // id() uses default implementation from trait
322
323    fn document_type(&self) -> DocumentType {
324        DocumentType::Strategy
325    }
326
327    fn title(&self) -> &str {
328        &self.core.title
329    }
330
331    fn metadata(&self) -> &DocumentMetadata {
332        &self.core.metadata
333    }
334
335    fn content(&self) -> &DocumentContent {
336        &self.core.content
337    }
338
339    fn core(&self) -> &super::traits::DocumentCore {
340        &self.core
341    }
342
343    fn can_transition_to(&self, phase: Phase) -> bool {
344        if let Ok(current_phase) = self.phase() {
345            use Phase::*;
346            matches!(
347                (current_phase, phase),
348                (Shaping, Design) | (Design, Ready) | (Ready, Active) | (Active, Completed)
349            )
350        } else {
351            false // Can't transition if we can't determine current phase
352        }
353    }
354
355    fn parent_id(&self) -> Option<&DocumentId> {
356        self.core.parent_id.as_ref()
357    }
358
359    fn blocked_by(&self) -> &[DocumentId] {
360        &self.core.blocked_by
361    }
362
363    fn validate(&self) -> Result<(), DocumentValidationError> {
364        // Strategy-specific validation rules
365        if self.title().trim().is_empty() {
366            return Err(DocumentValidationError::InvalidTitle(
367                "Strategy title cannot be empty".to_string(),
368            ));
369        }
370
371        if self.stakeholders.is_empty() {
372            return Err(DocumentValidationError::MissingRequiredField(
373                "Strategies must have at least one stakeholder".to_string(),
374            ));
375        }
376
377        Ok(())
378    }
379
380    fn exit_criteria_met(&self) -> bool {
381        // Check if all acceptance criteria checkboxes are checked
382        // This would typically parse the content for checkbox completion
383        // For now, return false as a placeholder
384        false
385    }
386
387    fn template(&self) -> DocumentTemplate {
388        DocumentTemplate {
389            frontmatter: self.frontmatter_template(),
390            content: self.content_template(),
391            acceptance_criteria: self.acceptance_criteria_template(),
392            file_extension: "md",
393        }
394    }
395
396    fn frontmatter_template(&self) -> &'static str {
397        include_str!("frontmatter.yaml")
398    }
399
400    fn content_template(&self) -> &'static str {
401        include_str!("content.md")
402    }
403
404    fn acceptance_criteria_template(&self) -> &'static str {
405        include_str!("acceptance_criteria.md")
406    }
407
408    fn transition_phase(
409        &mut self,
410        target_phase: Option<Phase>,
411    ) -> Result<Phase, DocumentValidationError> {
412        let current_phase = self.phase()?;
413
414        let new_phase = match target_phase {
415            Some(phase) => {
416                // Validate the transition is allowed
417                if !self.can_transition_to(phase) {
418                    return Err(DocumentValidationError::InvalidPhaseTransition {
419                        from: current_phase,
420                        to: phase,
421                    });
422                }
423                phase
424            }
425            None => {
426                // Auto-transition to next phase in sequence
427                match Self::next_phase_in_sequence(current_phase) {
428                    Some(next) => next,
429                    None => return Ok(current_phase), // Already at final phase
430                }
431            }
432        };
433
434        self.update_phase_tag(new_phase);
435        Ok(new_phase)
436    }
437
438    fn core_mut(&mut self) -> &mut super::traits::DocumentCore {
439        &mut self.core
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    use tempfile::tempdir;
448
449    #[tokio::test]
450    async fn test_strategy_new() {
451        let strategy = Strategy::new(
452            "Test Strategy".to_string(),
453            Some(DocumentId::from("parent-vision".to_string())),
454            Vec::new(),
455            vec![
456                Tag::Label("strategy".to_string()),
457                Tag::Phase(Phase::Shaping),
458            ],
459            false,
460            RiskLevel::Medium,
461            vec!["stakeholder1".to_string(), "stakeholder2".to_string()],
462        )
463        .expect("Failed to create strategy");
464
465        assert_eq!(strategy.title(), "Test Strategy");
466        assert_eq!(strategy.document_type(), DocumentType::Strategy);
467        assert!(!strategy.archived());
468        assert_eq!(strategy.risk_level(), RiskLevel::Medium);
469        assert_eq!(strategy.stakeholders().len(), 2);
470
471        // Round-trip test: write to file and read back
472        let temp_dir = tempdir().unwrap();
473        let file_path = temp_dir.path().join("test-strategy.md");
474
475        strategy.to_file(&file_path).await.unwrap();
476        let loaded_strategy = Strategy::from_file(&file_path).await.unwrap();
477
478        assert_eq!(loaded_strategy.title(), strategy.title());
479        assert_eq!(loaded_strategy.phase().unwrap(), strategy.phase().unwrap());
480        assert_eq!(loaded_strategy.content().body, strategy.content().body);
481        assert_eq!(loaded_strategy.archived(), strategy.archived());
482        assert_eq!(loaded_strategy.risk_level(), strategy.risk_level());
483        assert_eq!(loaded_strategy.stakeholders(), strategy.stakeholders());
484    }
485
486    #[tokio::test]
487    async fn test_strategy_from_content() {
488        let content = r##"---
489id: test-strategy
490level: strategy
491title: "Test Strategy"
492created_at: 2025-01-01T00:00:00Z
493updated_at: 2025-01-01T00:00:00Z
494parent: parent-vision
495blocked_by: []
496archived: false
497
498tags:
499  - "#strategy"
500  - "#phase/shaping"
501
502exit_criteria_met: false
503risk_level: medium
504stakeholders: ["stakeholder1", "stakeholder2"]
505---
506
507# Test Strategy
508
509## Vision Alignment
510
511This strategy aligns with our vision.
512
513## Current State
514
515We are here.
516
517## Acceptance Criteria
518
519- [ ] Strategy is clearly defined
520- [ ] Stakeholders are identified
521"##;
522
523        let strategy = Strategy::from_content(content).unwrap();
524
525        assert_eq!(strategy.title(), "Test Strategy");
526        assert_eq!(strategy.document_type(), DocumentType::Strategy);
527        assert!(!strategy.archived());
528        assert_eq!(strategy.risk_level(), RiskLevel::Medium);
529        assert_eq!(strategy.stakeholders().len(), 2);
530        assert_eq!(strategy.phase().unwrap(), Phase::Shaping);
531
532        // Round-trip test: write to file and read back
533        let temp_dir = tempdir().unwrap();
534        let file_path = temp_dir.path().join("test-strategy.md");
535
536        strategy.to_file(&file_path).await.unwrap();
537        let loaded_strategy = Strategy::from_file(&file_path).await.unwrap();
538
539        assert_eq!(loaded_strategy.title(), strategy.title());
540        assert_eq!(loaded_strategy.phase().unwrap(), strategy.phase().unwrap());
541        assert_eq!(loaded_strategy.content().body, strategy.content().body);
542        assert_eq!(loaded_strategy.risk_level(), strategy.risk_level());
543        assert_eq!(loaded_strategy.stakeholders(), strategy.stakeholders());
544    }
545
546    #[tokio::test]
547    async fn test_strategy_phase_transitions() {
548        let mut strategy = Strategy::new(
549            "Test Strategy".to_string(),
550            None,
551            Vec::new(),
552            vec![Tag::Phase(Phase::Shaping)],
553            false,
554            RiskLevel::Medium,
555            Vec::new(),
556        )
557        .expect("Failed to create strategy");
558
559        assert!(strategy.can_transition_to(Phase::Design));
560        assert!(!strategy.can_transition_to(Phase::Completed));
561
562        // Auto-transition from Shaping should go to Design
563        let new_phase = strategy.transition_phase(None).unwrap();
564        assert_eq!(new_phase, Phase::Design);
565        assert_eq!(strategy.phase().unwrap(), Phase::Design);
566
567        // Round-trip test after transition
568        let temp_dir = tempdir().unwrap();
569        let file_path = temp_dir.path().join("test-strategy.md");
570        strategy.to_file(&file_path).await.unwrap();
571        let loaded_strategy = Strategy::from_file(&file_path).await.unwrap();
572        assert_eq!(loaded_strategy.phase().unwrap(), Phase::Design);
573    }
574
575    #[tokio::test]
576    async fn test_strategy_validation() {
577        let strategy = Strategy::new(
578            "Test Strategy".to_string(),
579            None,
580            Vec::new(),
581            vec![
582                Tag::Label("strategy".to_string()),
583                Tag::Phase(Phase::Shaping),
584            ],
585            false,
586            RiskLevel::High,
587            vec!["key-stakeholder".to_string()],
588        )
589        .expect("Failed to create strategy");
590
591        assert!(strategy.validate().is_ok());
592        assert_eq!(strategy.risk_level(), RiskLevel::High);
593
594        // Round-trip test: write to file and read back
595        let temp_dir = tempdir().unwrap();
596        let file_path = temp_dir.path().join("test-strategy.md");
597
598        strategy.to_file(&file_path).await.unwrap();
599        let loaded_strategy = Strategy::from_file(&file_path).await.unwrap();
600
601        assert_eq!(loaded_strategy.title(), strategy.title());
602        assert_eq!(loaded_strategy.risk_level(), strategy.risk_level());
603        assert_eq!(loaded_strategy.stakeholders(), strategy.stakeholders());
604        assert!(loaded_strategy.validate().is_ok());
605    }
606}