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