metis_core/domain/documents/task/
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/// A Task document represents a concrete, actionable piece of work
12#[derive(Debug)]
13pub struct Task {
14    core: super::traits::DocumentCore,
15}
16
17impl Task {
18    /// Create a new Task document with content rendered from template
19    pub fn new(
20        title: String,
21        parent_id: Option<DocumentId>, // Usually an Initiative
22        parent_title: Option<String>,  // Title of parent for template rendering
23        blocked_by: Vec<DocumentId>,
24        tags: Vec<Tag>,
25        archived: bool,
26    ) -> Result<Self, DocumentValidationError> {
27        // Create fresh metadata
28        let metadata = DocumentMetadata::new();
29
30        // Render the content template
31        let template_content = include_str!("content.md");
32        let mut tera = Tera::default();
33        tera.add_raw_template("task_content", template_content)
34            .map_err(|e| {
35                DocumentValidationError::InvalidContent(format!("Template error: {}", e))
36            })?;
37
38        let mut context = Context::new();
39        context.insert("title", &title);
40        context.insert(
41            "parent_title",
42            &parent_title.unwrap_or_else(|| "Parent Initiative".to_string()),
43        );
44
45        let rendered_content = tera.render("task_content", &context).map_err(|e| {
46            DocumentValidationError::InvalidContent(format!("Template render error: {}", e))
47        })?;
48
49        let content = DocumentContent::new(&rendered_content);
50
51        Ok(Self {
52            core: super::traits::DocumentCore {
53                title,
54                metadata,
55                content,
56                parent_id,
57                blocked_by,
58                tags,
59                archived,
60            },
61        })
62    }
63
64    /// Create a Task document from existing data (used when loading from file)
65    pub fn from_parts(
66        title: String,
67        metadata: DocumentMetadata,
68        content: DocumentContent,
69        parent_id: Option<DocumentId>,
70        blocked_by: Vec<DocumentId>,
71        tags: Vec<Tag>,
72        archived: bool,
73    ) -> Self {
74        Self {
75            core: super::traits::DocumentCore {
76                title,
77                metadata,
78                content,
79                parent_id,
80                blocked_by,
81                tags,
82                archived,
83            },
84        }
85    }
86
87    /// Create a Task document by reading and parsing a file
88    pub async fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, DocumentValidationError> {
89        let raw_content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
90            DocumentValidationError::InvalidContent(format!("Failed to read file: {}", e))
91        })?;
92
93        Self::from_content(&raw_content)
94    }
95
96    /// Create a Task document from raw file content string
97    pub fn from_content(raw_content: &str) -> Result<Self, DocumentValidationError> {
98        // Parse frontmatter and content
99        let parsed = gray_matter::Matter::<gray_matter::engine::YAML>::new().parse(raw_content);
100
101        // Extract frontmatter data
102        let frontmatter = parsed.data.ok_or_else(|| {
103            DocumentValidationError::MissingRequiredField("frontmatter".to_string())
104        })?;
105
106        // Parse frontmatter into structured data
107        let fm_map = match frontmatter {
108            gray_matter::Pod::Hash(map) => map,
109            _ => {
110                return Err(DocumentValidationError::InvalidContent(
111                    "Frontmatter must be a hash/map".to_string(),
112                ))
113            }
114        };
115
116        // Extract required fields
117        let title = FrontmatterParser::extract_string(&fm_map, "title")?;
118        let archived = FrontmatterParser::extract_bool(&fm_map, "archived").unwrap_or(false);
119
120        // Parse timestamps
121        let created_at = FrontmatterParser::extract_datetime(&fm_map, "created_at")?;
122        let updated_at = FrontmatterParser::extract_datetime(&fm_map, "updated_at")?;
123        let exit_criteria_met =
124            FrontmatterParser::extract_bool(&fm_map, "exit_criteria_met").unwrap_or(false);
125
126        // Parse tags
127        let tags = FrontmatterParser::extract_tags(&fm_map)?;
128
129        // Verify this is actually a task document
130        let level = FrontmatterParser::extract_string(&fm_map, "level")?;
131        if level != "task" {
132            return Err(DocumentValidationError::InvalidContent(format!(
133                "Expected level 'task', found '{}'",
134                level
135            )));
136        }
137
138        // Extract task-specific fields
139        let parent_id = FrontmatterParser::extract_string(&fm_map, "parent")
140            .ok()
141            .map(DocumentId::from);
142        let blocked_by = FrontmatterParser::extract_string_array(&fm_map, "blocked_by")
143            .unwrap_or_default()
144            .into_iter()
145            .map(DocumentId::from)
146            .collect();
147
148        // Create metadata and content
149        let metadata =
150            DocumentMetadata::from_frontmatter(created_at, updated_at, exit_criteria_met);
151        let content = DocumentContent::from_markdown(&parsed.content);
152
153        Ok(Self::from_parts(
154            title, metadata, content, parent_id, blocked_by, tags, archived,
155        ))
156    }
157
158    /// Get the next phase in the Task sequence
159    fn next_phase_in_sequence(current: Phase) -> Option<Phase> {
160        use Phase::*;
161        match current {
162            Todo => Some(Active),
163            Active => Some(Completed),
164            Completed => None, // Final phase
165            Blocked => None,   // Blocked doesn't auto-transition
166            _ => None,         // Invalid phase for Task
167        }
168    }
169
170    /// Update the phase tag in the document's tags
171    fn update_phase_tag(&mut self, new_phase: Phase) {
172        // Remove any existing phase tags
173        self.core.tags.retain(|tag| !matches!(tag, Tag::Phase(_)));
174        // Add the new phase tag
175        self.core.tags.push(Tag::Phase(new_phase));
176        // Update timestamp
177        self.core.metadata.updated_at = Utc::now();
178    }
179
180    /// Write the Task document to a file
181    pub async fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), DocumentValidationError> {
182        let content = self.to_content()?;
183        std::fs::write(path.as_ref(), content).map_err(|e| {
184            DocumentValidationError::InvalidContent(format!("Failed to write file: {}", e))
185        })
186    }
187
188    /// Convert the Task document to its markdown string representation using templates
189    pub fn to_content(&self) -> Result<String, DocumentValidationError> {
190        let mut tera = Tera::default();
191
192        // Add the frontmatter template to Tera
193        tera.add_raw_template("frontmatter", self.frontmatter_template())
194            .map_err(|e| {
195                DocumentValidationError::InvalidContent(format!("Template error: {}", e))
196            })?;
197
198        // Create context with all document data
199        let mut context = Context::new();
200        context.insert("slug", &self.id().to_string());
201        context.insert("title", self.title());
202        context.insert("created_at", &self.metadata().created_at.to_rfc3339());
203        context.insert("updated_at", &self.metadata().updated_at.to_rfc3339());
204        context.insert("archived", &self.archived().to_string());
205        context.insert(
206            "exit_criteria_met",
207            &self.metadata().exit_criteria_met.to_string(),
208        );
209        context.insert(
210            "parent_id",
211            &self
212                .parent_id()
213                .map(|id| id.to_string())
214                .unwrap_or_default(),
215        );
216        let blocked_by_list: Vec<String> =
217            self.blocked_by().iter().map(|id| id.to_string()).collect();
218        context.insert("blocked_by", &blocked_by_list);
219
220        // Convert tags to strings
221        let tag_strings: Vec<String> = self.tags().iter().map(|tag| tag.to_str()).collect();
222        context.insert("tags", &tag_strings);
223
224        // Render frontmatter
225        let frontmatter = tera.render("frontmatter", &context).map_err(|e| {
226            DocumentValidationError::InvalidContent(format!("Frontmatter render error: {}", e))
227        })?;
228
229        // Use the actual content body
230        let content_body = &self.content().body;
231
232        // Use actual acceptance criteria if present, otherwise empty string
233        let acceptance_criteria = if let Some(ac) = &self.content().acceptance_criteria {
234            format!("\n\n## Acceptance Criteria\n\n{}", ac)
235        } else {
236            String::new()
237        };
238
239        // Combine everything
240        Ok(format!(
241            "---\n{}\n---\n\n{}{}",
242            frontmatter.trim_end(),
243            content_body,
244            acceptance_criteria
245        ))
246    }
247}
248
249impl Document for Task {
250    // id() uses default implementation from trait
251
252    fn document_type(&self) -> DocumentType {
253        DocumentType::Task
254    }
255
256    fn title(&self) -> &str {
257        &self.core.title
258    }
259
260    fn metadata(&self) -> &DocumentMetadata {
261        &self.core.metadata
262    }
263
264    fn content(&self) -> &DocumentContent {
265        &self.core.content
266    }
267
268    fn core(&self) -> &super::traits::DocumentCore {
269        &self.core
270    }
271
272    fn can_transition_to(&self, phase: Phase) -> bool {
273        if let Ok(current_phase) = self.phase() {
274            use Phase::*;
275            match (current_phase, phase) {
276                (Todo, Active) => true,
277                (Active, Completed) => true,
278                (Active, Blocked) => true,
279                (Todo, Blocked) => true, // Can pre emptively be blocked while in backlog
280                (Blocked, Active) => true,
281                (Blocked, Todo) => true, // Can go back to todo if unblocked
282                _ => false,
283            }
284        } else {
285            false // Can't transition if we can't determine current phase
286        }
287    }
288
289    fn parent_id(&self) -> Option<&DocumentId> {
290        self.core.parent_id.as_ref()
291    }
292
293    fn blocked_by(&self) -> &[DocumentId] {
294        &self.core.blocked_by
295    }
296
297    fn validate(&self) -> Result<(), DocumentValidationError> {
298        // Task-specific validation rules
299        if self.title().trim().is_empty() {
300            return Err(DocumentValidationError::InvalidTitle(
301                "Task title cannot be empty".to_string(),
302            ));
303        }
304
305        // Tasks should typically have a parent (Initiative)
306        if self.parent_id().is_none() {
307            return Err(DocumentValidationError::MissingRequiredField(
308                "Tasks should have a parent Initiative".to_string(),
309            ));
310        }
311
312        // If blocked, must have blocking documents listed
313        if let Ok(Phase::Blocked) = self.phase() {
314            if self.blocked_by().is_empty() {
315                return Err(DocumentValidationError::InvalidContent(
316                    "Blocked tasks must specify what they are blocked by".to_string(),
317                ));
318            }
319        }
320
321        Ok(())
322    }
323
324    fn exit_criteria_met(&self) -> bool {
325        // Check if all acceptance criteria checkboxes are checked
326        // This would typically parse the content for checkbox completion
327        // For now, return false as a placeholder
328        false
329    }
330
331    fn template(&self) -> DocumentTemplate {
332        DocumentTemplate {
333            frontmatter: self.frontmatter_template(),
334            content: self.content_template(),
335            acceptance_criteria: self.acceptance_criteria_template(),
336            file_extension: "md",
337        }
338    }
339
340    fn frontmatter_template(&self) -> &'static str {
341        include_str!("frontmatter.yaml")
342    }
343
344    fn content_template(&self) -> &'static str {
345        include_str!("content.md")
346    }
347
348    fn acceptance_criteria_template(&self) -> &'static str {
349        include_str!("acceptance_criteria.md")
350    }
351
352    fn transition_phase(
353        &mut self,
354        target_phase: Option<Phase>,
355    ) -> Result<Phase, DocumentValidationError> {
356        let current_phase = self.phase()?;
357
358        let new_phase = match target_phase {
359            Some(phase) => {
360                // Validate the transition is allowed
361                if !self.can_transition_to(phase) {
362                    return Err(DocumentValidationError::InvalidPhaseTransition {
363                        from: current_phase,
364                        to: phase,
365                    });
366                }
367                phase
368            }
369            None => {
370                // Auto-transition to next phase in sequence
371                match Self::next_phase_in_sequence(current_phase) {
372                    Some(next) => next,
373                    None => return Ok(current_phase), // Already at final phase or blocked
374                }
375            }
376        };
377
378        self.update_phase_tag(new_phase);
379        Ok(new_phase)
380    }
381
382    fn core_mut(&mut self) -> &mut super::traits::DocumentCore {
383        &mut self.core
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::domain::documents::traits::DocumentValidationError;
391    use tempfile::tempdir;
392
393    #[tokio::test]
394    async fn test_task_from_content() {
395        let content = r##"---
396id: test-task
397level: task
398title: "Test Task"
399created_at: 2025-01-01T00:00:00Z
400updated_at: 2025-01-01T00:00:00Z
401archived: false
402parent: initiative-001
403blocked_by: []
404
405tags:
406  - "#task"
407  - "#phase/todo"
408
409exit_criteria_met: false
410---
411
412# Test Task
413
414## Description
415
416This is a test task for our system.
417
418## Implementation Notes
419
420Details on how to implement this.
421
422## Acceptance Criteria
423
424- [ ] Implementation is complete
425- [ ] Tests pass
426"##;
427
428        let task = Task::from_content(content).unwrap();
429
430        assert_eq!(task.title(), "Test Task");
431        assert_eq!(task.document_type(), DocumentType::Task);
432        assert!(!task.archived());
433        assert_eq!(task.tags().len(), 2);
434        assert_eq!(task.phase().unwrap(), Phase::Todo);
435        assert!(task.content().has_acceptance_criteria());
436
437        // Round-trip test: write to file and read back
438        let temp_dir = tempdir().unwrap();
439        let file_path = temp_dir.path().join("test-task.md");
440
441        task.to_file(&file_path).await.unwrap();
442        let loaded_task = Task::from_file(&file_path).await.unwrap();
443
444        assert_eq!(loaded_task.title(), task.title());
445        assert_eq!(loaded_task.phase().unwrap(), task.phase().unwrap());
446        assert_eq!(loaded_task.content().body, task.content().body);
447        assert_eq!(loaded_task.archived(), task.archived());
448        assert_eq!(loaded_task.tags().len(), task.tags().len());
449    }
450
451    #[test]
452    fn test_task_invalid_level() {
453        let content = r##"---
454id: test-doc
455level: strategy
456title: "Test Strategy"
457created_at: 2025-01-01T00:00:00Z
458updated_at: 2025-01-01T00:00:00Z
459archived: false
460tags:
461  - "#strategy"
462  - "#phase/shaping"
463exit_criteria_met: false
464---
465
466# Test Strategy
467"##;
468
469        let result = Task::from_content(content);
470        assert!(result.is_err());
471        match result.unwrap_err() {
472            DocumentValidationError::InvalidContent(msg) => {
473                assert!(msg.contains("Expected level 'task'"));
474            }
475            _ => panic!("Expected InvalidContent error"),
476        }
477    }
478
479    #[test]
480    fn test_task_validation() {
481        let task = Task::new(
482            "Test Task".to_string(),
483            Some(DocumentId::from("parent-initiative")),
484            Some("Parent Initiative".to_string()),
485            vec![],
486            vec![Tag::Label("task".to_string()), Tag::Phase(Phase::Todo)],
487            false,
488        )
489        .expect("Failed to create task");
490
491        assert!(task.validate().is_ok());
492
493        // Test validation failure - no parent
494        let task_no_parent = Task::new(
495            "Test Task".to_string(),
496            None, // No parent
497            None,
498            vec![],
499            vec![Tag::Phase(Phase::Todo)],
500            false,
501        )
502        .expect("Failed to create task");
503
504        assert!(task_no_parent.validate().is_err());
505    }
506
507    #[test]
508    fn test_task_blocked_validation() {
509        // Task marked as blocked but no blocking documents
510        let blocked_task = Task::new(
511            "Blocked Task".to_string(),
512            Some(DocumentId::from("parent-initiative")),
513            Some("Parent Initiative".to_string()),
514            vec![], // No blocking documents
515            vec![Tag::Phase(Phase::Blocked)],
516            false,
517        )
518        .expect("Failed to create task");
519
520        assert!(blocked_task.validate().is_err());
521
522        // Task marked as blocked with blocking documents
523        let properly_blocked_task = Task::new(
524            "Blocked Task".to_string(),
525            Some(DocumentId::from("parent-initiative")),
526            Some("Parent Initiative".to_string()),
527            vec![DocumentId::from("blocking-task")],
528            vec![Tag::Phase(Phase::Blocked)],
529            false,
530        )
531        .expect("Failed to create task");
532
533        assert!(properly_blocked_task.validate().is_ok());
534    }
535
536    #[test]
537    fn test_task_phase_transitions() {
538        let task = Task::new(
539            "Test Task".to_string(),
540            Some(DocumentId::from("parent-initiative")),
541            Some("Parent Initiative".to_string()),
542            vec![],
543            vec![Tag::Phase(Phase::Todo)],
544            false,
545        )
546        .expect("Failed to create task");
547
548        assert!(task.can_transition_to(Phase::Active));
549        assert!(task.can_transition_to(Phase::Blocked));
550        assert!(!task.can_transition_to(Phase::Completed));
551        assert!(!task.can_transition_to(Phase::Design));
552    }
553
554    #[test]
555    fn test_task_active_phase_transitions() {
556        let active_task = Task::new(
557            "Active Task".to_string(),
558            Some(DocumentId::from("parent-initiative")),
559            Some("Parent Initiative".to_string()),
560            vec![],
561            vec![Tag::Phase(Phase::Active)],
562            false,
563        )
564        .expect("Failed to create task");
565
566        assert!(active_task.can_transition_to(Phase::Completed));
567        assert!(active_task.can_transition_to(Phase::Blocked));
568        assert!(!active_task.can_transition_to(Phase::Todo));
569    }
570
571    #[test]
572    fn test_task_blocked_phase_transitions() {
573        let blocked_task = Task::new(
574            "Blocked Task".to_string(),
575            Some(DocumentId::from("parent-initiative")),
576            Some("Parent Initiative".to_string()),
577            vec![DocumentId::from("blocking-task")],
578            vec![Tag::Phase(Phase::Blocked)],
579            false,
580        )
581        .expect("Failed to create task");
582
583        assert!(blocked_task.can_transition_to(Phase::Active));
584        assert!(blocked_task.can_transition_to(Phase::Todo));
585        assert!(!blocked_task.can_transition_to(Phase::Completed));
586    }
587
588    #[test]
589    fn test_task_transition_phase_auto() {
590        let mut task = Task::new(
591            "Test Task".to_string(),
592            Some(DocumentId::from("parent-initiative")),
593            Some("Parent Initiative".to_string()),
594            vec![],
595            vec![Tag::Phase(Phase::Todo)],
596            false,
597        )
598        .expect("Failed to create task");
599
600        // Auto-transition from Todo should go to Active
601        let new_phase = task.transition_phase(None).unwrap();
602        assert_eq!(new_phase, Phase::Active);
603        assert_eq!(task.phase().unwrap(), Phase::Active);
604
605        // Auto-transition from Active should go to Completed
606        let new_phase = task.transition_phase(None).unwrap();
607        assert_eq!(new_phase, Phase::Completed);
608        assert_eq!(task.phase().unwrap(), Phase::Completed);
609
610        // Auto-transition from Completed should stay at Completed (final phase)
611        let new_phase = task.transition_phase(None).unwrap();
612        assert_eq!(new_phase, Phase::Completed);
613        assert_eq!(task.phase().unwrap(), Phase::Completed);
614    }
615
616    #[test]
617    fn test_task_transition_phase_blocking() {
618        let mut task = Task::new(
619            "Test Task".to_string(),
620            Some(DocumentId::from("parent-initiative")),
621            Some("Parent Initiative".to_string()),
622            vec![DocumentId::from("blocking-task")],
623            vec![Tag::Phase(Phase::Todo)],
624            false,
625        )
626        .expect("Failed to create task");
627
628        // Explicit transition from Todo to Blocked
629        let new_phase = task.transition_phase(Some(Phase::Blocked)).unwrap();
630        assert_eq!(new_phase, Phase::Blocked);
631        assert_eq!(task.phase().unwrap(), Phase::Blocked);
632
633        // Transition from Blocked back to Active (unblocking)
634        let new_phase = task.transition_phase(Some(Phase::Active)).unwrap();
635        assert_eq!(new_phase, Phase::Active);
636        assert_eq!(task.phase().unwrap(), Phase::Active);
637
638        // Blocked doesn't auto-transition
639        task.core.tags.retain(|tag| !matches!(tag, Tag::Phase(_)));
640        task.core.tags.push(Tag::Phase(Phase::Blocked));
641        let new_phase = task.transition_phase(None).unwrap();
642        assert_eq!(new_phase, Phase::Blocked); // Should stay blocked
643    }
644
645    #[test]
646    fn test_task_transition_phase_invalid() {
647        let mut task = Task::new(
648            "Test Task".to_string(),
649            Some(DocumentId::from("parent-initiative")),
650            Some("Parent Initiative".to_string()),
651            vec![],
652            vec![Tag::Phase(Phase::Todo)],
653            false,
654        )
655        .expect("Failed to create task");
656
657        // Invalid transition from Todo to Completed (must go through Active)
658        let result = task.transition_phase(Some(Phase::Completed));
659        assert!(result.is_err());
660        match result.unwrap_err() {
661            DocumentValidationError::InvalidPhaseTransition { from, to } => {
662                assert_eq!(from, Phase::Todo);
663                assert_eq!(to, Phase::Completed);
664            }
665            _ => panic!("Expected InvalidPhaseTransition error"),
666        }
667
668        // Should still be in Todo phase
669        assert_eq!(task.phase().unwrap(), Phase::Todo);
670    }
671
672    #[test]
673    fn test_task_update_section() {
674        // First create a task with the template
675        let mut task = Task::new(
676            "Test Task".to_string(),
677            Some(DocumentId::from("parent-initiative")),
678            Some("Parent Initiative".to_string()),
679            vec![],
680            vec![Tag::Phase(Phase::Todo)],
681            false,
682        )
683        .expect("Failed to create task");
684
685        // Then update its content to have specific test content
686        task.core_mut().content = DocumentContent::new(
687            "## Description\n\nOriginal description\n\n## Implementation Notes\n\nOriginal notes",
688        );
689
690        // Replace existing section
691        task.update_section("Updated task description", "Description", false)
692            .unwrap();
693        let content = task.content().body.clone();
694        assert!(content.contains("## Description\n\nUpdated task description"));
695        assert!(!content.contains("Original description"));
696
697        // Append to existing section
698        task.update_section(
699            "Additional implementation details",
700            "Implementation Notes",
701            true,
702        )
703        .unwrap();
704        let content = task.content().body.clone();
705        assert!(content.contains("Original notes"));
706        assert!(content.contains("Additional implementation details"));
707
708        // Add new section
709        task.update_section("Test approach details", "Testing Strategy", false)
710            .unwrap();
711        let content = task.content().body.clone();
712        assert!(content.contains("## Testing Strategy\n\nTest approach details"));
713    }
714}