Skip to main content

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            // Delegate to DocumentType - the single source of truth
356            DocumentType::Task.can_transition(current_phase, phase)
357        } else {
358            false // Can't transition if we can't determine current phase
359        }
360    }
361
362    fn parent_id(&self) -> Option<&DocumentId> {
363        self.core.parent_id.as_ref()
364    }
365
366    fn blocked_by(&self) -> &[DocumentId] {
367        &self.core.blocked_by
368    }
369
370    fn validate(&self) -> Result<(), DocumentValidationError> {
371        // Task-specific validation rules
372        if self.title().trim().is_empty() {
373            return Err(DocumentValidationError::InvalidTitle(
374                "Task title cannot be empty".to_string(),
375            ));
376        }
377
378        // Tasks should have a parent (Initiative) unless they are in Backlog phase
379        if self.parent_id().is_none() {
380            // Allow no parent only if task is in Backlog phase
381            if let Ok(phase) = self.phase() {
382                if phase != Phase::Backlog {
383                    return Err(DocumentValidationError::MissingRequiredField(
384                        "Tasks should have a parent Initiative unless in Backlog phase".to_string(),
385                    ));
386                }
387            } else {
388                return Err(DocumentValidationError::MissingRequiredField(
389                    "Tasks should have a parent Initiative".to_string(),
390                ));
391            }
392        }
393
394        // If blocked, must have blocking documents listed
395        if let Ok(Phase::Blocked) = self.phase() {
396            if self.blocked_by().is_empty() {
397                return Err(DocumentValidationError::InvalidContent(
398                    "Blocked tasks must specify what they are blocked by".to_string(),
399                ));
400            }
401        }
402
403        Ok(())
404    }
405
406    fn exit_criteria_met(&self) -> bool {
407        // Check if all acceptance criteria checkboxes are checked
408        // This would typically parse the content for checkbox completion
409        // For now, return false as a placeholder
410        false
411    }
412
413    fn template(&self) -> DocumentTemplate {
414        DocumentTemplate {
415            frontmatter: self.frontmatter_template(),
416            content: self.content_template(),
417            acceptance_criteria: self.acceptance_criteria_template(),
418            file_extension: "md",
419        }
420    }
421
422    fn frontmatter_template(&self) -> &'static str {
423        include_str!("frontmatter.yaml")
424    }
425
426    fn content_template(&self) -> &'static str {
427        include_str!("content.md")
428    }
429
430    fn acceptance_criteria_template(&self) -> &'static str {
431        include_str!("acceptance_criteria.md")
432    }
433
434    fn transition_phase(
435        &mut self,
436        target_phase: Option<Phase>,
437    ) -> Result<Phase, DocumentValidationError> {
438        let current_phase = self.phase()?;
439
440        let new_phase = match target_phase {
441            Some(phase) => {
442                // Validate the transition is allowed
443                if !self.can_transition_to(phase) {
444                    return Err(DocumentValidationError::InvalidPhaseTransition {
445                        from: current_phase,
446                        to: phase,
447                    });
448                }
449                phase
450            }
451            None => {
452                // Auto-transition to next phase in sequence
453                match Self::next_phase_in_sequence(current_phase) {
454                    Some(next) => next,
455                    None => return Ok(current_phase), // Already at final phase or blocked
456                }
457            }
458        };
459
460        self.update_phase_tag(new_phase);
461        Ok(new_phase)
462    }
463
464    fn core_mut(&mut self) -> &mut super::traits::DocumentCore {
465        &mut self.core
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use crate::domain::documents::traits::DocumentValidationError;
473    use tempfile::tempdir;
474
475    #[tokio::test]
476    async fn test_task_from_content() {
477        let content = r##"---
478id: test-task
479level: task
480title: "Test Task"
481created_at: 2025-01-01T00:00:00Z
482updated_at: 2025-01-01T00:00:00Z
483archived: false
484parent: initiative-001
485blocked_by: []
486short_code: TEST-T-9001
487
488tags:
489  - "#task"
490  - "#phase/todo"
491
492exit_criteria_met: false
493---
494
495# Test Task
496
497## Description
498
499This is a test task for our system.
500
501## Implementation Notes
502
503Details on how to implement this.
504
505## Acceptance Criteria
506
507- [ ] Implementation is complete
508- [ ] Tests pass
509"##;
510
511        let task = Task::from_content(content).unwrap();
512
513        assert_eq!(task.title(), "Test Task");
514        assert_eq!(task.document_type(), DocumentType::Task);
515        assert!(!task.archived());
516        assert_eq!(task.tags().len(), 2);
517        assert_eq!(task.phase().unwrap(), Phase::Todo);
518        assert!(task.content().has_acceptance_criteria());
519
520        // Round-trip test: write to file and read back
521        let temp_dir = tempdir().unwrap();
522        let file_path = temp_dir.path().join("test-task.md");
523
524        task.to_file(&file_path).await.unwrap();
525        let loaded_task = Task::from_file(&file_path).await.unwrap();
526
527        assert_eq!(loaded_task.title(), task.title());
528        assert_eq!(loaded_task.phase().unwrap(), task.phase().unwrap());
529        assert_eq!(loaded_task.content().body, task.content().body);
530        assert_eq!(loaded_task.archived(), task.archived());
531        assert_eq!(loaded_task.tags().len(), task.tags().len());
532    }
533
534    #[test]
535    fn test_task_invalid_level() {
536        let content = r##"---
537id: test-doc
538level: strategy
539title: "Test Strategy"
540created_at: 2025-01-01T00:00:00Z
541updated_at: 2025-01-01T00:00:00Z
542archived: false
543tags:
544  - "#strategy"
545  - "#phase/shaping"
546exit_criteria_met: false
547---
548
549# Test Strategy
550"##;
551
552        let result = Task::from_content(content);
553        assert!(result.is_err());
554        match result.unwrap_err() {
555            DocumentValidationError::InvalidContent(msg) => {
556                assert!(msg.contains("Expected level 'task'"));
557            }
558            _ => panic!("Expected InvalidContent error"),
559        }
560    }
561
562    #[test]
563    fn test_task_validation() {
564        let task = Task::new(
565            "Test Task".to_string(),
566            Some(DocumentId::from("parent-initiative")), // parent_id
567            Some("Parent Initiative".to_string()),       // parent_title
568            Some(DocumentId::from("parent-strategy")),   // strategy_id
569            Some(DocumentId::from("parent-initiative")), // initiative_id
570            vec![],                                      // blocked_by
571            vec![Tag::Label("task".to_string()), Tag::Phase(Phase::Todo)],
572            false,
573            "TEST-T-0401".to_string(),
574        )
575        .expect("Failed to create task");
576
577        assert!(task.validate().is_ok());
578
579        // Test validation failure - no parent
580        let task_no_parent = Task::new(
581            "Test Task".to_string(),
582            None,   // No parent
583            None,   // No parent title
584            None,   // No strategy
585            None,   // No initiative
586            vec![], // blocked_by
587            vec![Tag::Phase(Phase::Todo)],
588            false,
589            "TEST-T-0401".to_string(),
590        )
591        .expect("Failed to create task");
592
593        assert!(task_no_parent.validate().is_err());
594    }
595
596    #[test]
597    fn test_task_blocked_validation() {
598        // Task marked as blocked but no blocking documents
599        let blocked_task = Task::new(
600            "Blocked Task".to_string(),
601            Some(DocumentId::from("parent-initiative")), // parent_id
602            Some("Parent Initiative".to_string()),       // parent_title
603            Some(DocumentId::from("parent-strategy")),   // strategy_id
604            Some(DocumentId::from("parent-initiative")), // initiative_id
605            vec![],                                      // No blocking documents
606            vec![Tag::Phase(Phase::Blocked)],
607            false,
608            "TEST-T-0401".to_string(),
609        )
610        .expect("Failed to create task");
611
612        assert!(blocked_task.validate().is_err());
613
614        // Task marked as blocked with blocking documents
615        let properly_blocked_task = Task::new(
616            "Blocked Task".to_string(),
617            Some(DocumentId::from("parent-initiative")), // parent_id
618            Some("Parent Initiative".to_string()),       // parent_title
619            Some(DocumentId::from("parent-strategy")),   // strategy_id
620            Some(DocumentId::from("parent-initiative")), // initiative_id
621            vec![DocumentId::from("blocking-task")],
622            vec![Tag::Phase(Phase::Blocked)],
623            false,
624            "TEST-T-0401".to_string(),
625        )
626        .expect("Failed to create task");
627
628        assert!(properly_blocked_task.validate().is_ok());
629    }
630
631    #[test]
632    fn test_task_phase_transitions() {
633        let task = Task::new(
634            "Test Task".to_string(),
635            Some(DocumentId::from("parent-initiative")), // parent_id
636            Some("Parent Initiative".to_string()),       // parent_title
637            Some(DocumentId::from("parent-strategy")),   // strategy_id
638            Some(DocumentId::from("parent-initiative")), // initiative_id
639            vec![],
640            vec![Tag::Phase(Phase::Todo)],
641            false,
642            "TEST-T-0401".to_string(),
643        )
644        .expect("Failed to create task");
645
646        assert!(task.can_transition_to(Phase::Active));
647        assert!(task.can_transition_to(Phase::Blocked));
648        assert!(!task.can_transition_to(Phase::Completed));
649        assert!(!task.can_transition_to(Phase::Design));
650    }
651
652    #[test]
653    fn test_task_active_phase_transitions() {
654        let active_task = Task::new(
655            "Active 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![],
661            vec![Tag::Phase(Phase::Active)],
662            false,
663            "TEST-T-0401".to_string(),
664        )
665        .expect("Failed to create task");
666
667        assert!(active_task.can_transition_to(Phase::Completed));
668        assert!(active_task.can_transition_to(Phase::Blocked));
669        assert!(!active_task.can_transition_to(Phase::Todo));
670    }
671
672    #[test]
673    fn test_task_blocked_phase_transitions() {
674        let blocked_task = Task::new(
675            "Blocked 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![DocumentId::from("blocking-task")],
681            vec![Tag::Phase(Phase::Blocked)],
682            false,
683            "TEST-T-0401".to_string(),
684        )
685        .expect("Failed to create task");
686
687        assert!(blocked_task.can_transition_to(Phase::Active));
688        assert!(blocked_task.can_transition_to(Phase::Todo));
689        assert!(!blocked_task.can_transition_to(Phase::Completed));
690    }
691
692    #[test]
693    fn test_task_transition_phase_auto() {
694        let mut task = Task::new(
695            "Test Task".to_string(),
696            Some(DocumentId::from("parent-initiative")), // parent_id
697            Some("Parent Initiative".to_string()),       // parent_title
698            Some(DocumentId::from("parent-strategy")),   // strategy_id
699            Some(DocumentId::from("parent-initiative")), // initiative_id
700            vec![],
701            vec![Tag::Phase(Phase::Todo)],
702            false,
703            "TEST-T-0401".to_string(),
704        )
705        .expect("Failed to create task");
706
707        // Auto-transition from Todo should go to Active
708        let new_phase = task.transition_phase(None).unwrap();
709        assert_eq!(new_phase, Phase::Active);
710        assert_eq!(task.phase().unwrap(), Phase::Active);
711
712        // Auto-transition from Active should go to Completed
713        let new_phase = task.transition_phase(None).unwrap();
714        assert_eq!(new_phase, Phase::Completed);
715        assert_eq!(task.phase().unwrap(), Phase::Completed);
716
717        // Auto-transition from Completed should stay at Completed (final phase)
718        let new_phase = task.transition_phase(None).unwrap();
719        assert_eq!(new_phase, Phase::Completed);
720        assert_eq!(task.phase().unwrap(), Phase::Completed);
721    }
722
723    #[test]
724    fn test_task_transition_phase_blocking() {
725        let mut task = Task::new(
726            "Test Task".to_string(),
727            Some(DocumentId::from("parent-initiative")), // parent_id
728            Some("Parent Initiative".to_string()),       // parent_title
729            Some(DocumentId::from("parent-strategy")),   // strategy_id
730            Some(DocumentId::from("parent-initiative")), // initiative_id
731            vec![DocumentId::from("blocking-task")],
732            vec![Tag::Phase(Phase::Todo)],
733            false,
734            "TEST-T-0401".to_string(),
735        )
736        .expect("Failed to create task");
737
738        // Explicit transition from Todo to Blocked
739        let new_phase = task.transition_phase(Some(Phase::Blocked)).unwrap();
740        assert_eq!(new_phase, Phase::Blocked);
741        assert_eq!(task.phase().unwrap(), Phase::Blocked);
742
743        // Transition from Blocked back to Active (unblocking)
744        let new_phase = task.transition_phase(Some(Phase::Active)).unwrap();
745        assert_eq!(new_phase, Phase::Active);
746        assert_eq!(task.phase().unwrap(), Phase::Active);
747
748        // Blocked doesn't auto-transition
749        task.core.tags.retain(|tag| !matches!(tag, Tag::Phase(_)));
750        task.core.tags.push(Tag::Phase(Phase::Blocked));
751        let new_phase = task.transition_phase(None).unwrap();
752        assert_eq!(new_phase, Phase::Blocked); // Should stay blocked
753    }
754
755    #[test]
756    fn test_task_transition_phase_invalid() {
757        let mut task = Task::new(
758            "Test Task".to_string(),
759            Some(DocumentId::from("parent-initiative")), // parent_id
760            Some("Parent Initiative".to_string()),       // parent_title
761            Some(DocumentId::from("parent-strategy")),   // strategy_id
762            Some(DocumentId::from("parent-initiative")), // initiative_id
763            vec![],
764            vec![Tag::Phase(Phase::Todo)],
765            false,
766            "TEST-T-0401".to_string(),
767        )
768        .expect("Failed to create task");
769
770        // Invalid transition from Todo to Completed (must go through Active)
771        let result = task.transition_phase(Some(Phase::Completed));
772        assert!(result.is_err());
773        match result.unwrap_err() {
774            DocumentValidationError::InvalidPhaseTransition { from, to } => {
775                assert_eq!(from, Phase::Todo);
776                assert_eq!(to, Phase::Completed);
777            }
778            _ => panic!("Expected InvalidPhaseTransition error"),
779        }
780
781        // Should still be in Todo phase
782        assert_eq!(task.phase().unwrap(), Phase::Todo);
783    }
784
785    #[test]
786    fn test_task_update_section() {
787        // First create a task with the template
788        let mut task = Task::new(
789            "Test Task".to_string(),
790            Some(DocumentId::from("parent-initiative")), // parent_id
791            Some("Parent Initiative".to_string()),       // parent_title
792            Some(DocumentId::from("parent-strategy")),   // strategy_id
793            Some(DocumentId::from("parent-initiative")), // initiative_id
794            vec![],
795            vec![Tag::Phase(Phase::Todo)],
796            false,
797            "TEST-T-0401".to_string(),
798        )
799        .expect("Failed to create task");
800
801        // Then update its content to have specific test content
802        task.core_mut().content = DocumentContent::new(
803            "## Description\n\nOriginal description\n\n## Implementation Notes\n\nOriginal notes",
804        );
805
806        // Replace existing section
807        task.update_section("Updated task description", "Description", false)
808            .unwrap();
809        let content = task.content().body.clone();
810        assert!(content.contains("## Description\n\nUpdated task description"));
811        assert!(!content.contains("Original description"));
812
813        // Append to existing section
814        task.update_section(
815            "Additional implementation details",
816            "Implementation Notes",
817            true,
818        )
819        .unwrap();
820        let content = task.content().body.clone();
821        assert!(content.contains("Original notes"));
822        assert!(content.contains("Additional implementation details"));
823
824        // Add new section
825        task.update_section("Test approach details", "Testing Strategy", false)
826            .unwrap();
827        let content = task.content().body.clone();
828        assert!(content.contains("## Testing Strategy\n\nTest approach details"));
829    }
830}