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