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