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#[derive(Debug)]
13pub struct Task {
14 core: super::traits::DocumentCore,
15}
16
17impl Task {
18 #[allow(clippy::too_many_arguments)]
20 pub fn new(
21 title: String,
22 parent_id: Option<DocumentId>, parent_title: Option<String>, strategy_id: Option<DocumentId>, initiative_id: Option<DocumentId>, blocked_by: Vec<DocumentId>,
27 tags: Vec<Tag>,
28 archived: bool,
29 short_code: String,
30 ) -> Result<Self, DocumentValidationError> {
31 let metadata = DocumentMetadata::new(short_code);
33
34 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 #[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 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 pub fn from_content(raw_content: &str) -> Result<Self, DocumentValidationError> {
109 let parsed = gray_matter::Matter::<gray_matter::engine::YAML>::new().parse(raw_content);
111
112 let frontmatter = parsed.data.ok_or_else(|| {
114 DocumentValidationError::MissingRequiredField("frontmatter".to_string())
115 })?;
116
117 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 let title = FrontmatterParser::extract_string(&fm_map, "title")?;
129 let archived = FrontmatterParser::extract_bool(&fm_map, "archived").unwrap_or(false);
130
131 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 let tags = FrontmatterParser::extract_tags(&fm_map)?;
139
140 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 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 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 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 fn next_phase_in_sequence(current: Phase) -> Option<Phase> {
190 use Phase::*;
191 match current {
192 Backlog => None, Todo => Some(Active),
194 Active => Some(Completed),
195 Completed => None, Blocked => None, _ => None, }
199 }
200
201 fn update_phase_tag(&mut self, new_phase: Phase) {
203 self.core.tags.retain(|tag| !matches!(tag, Tag::Phase(_)));
205 self.core.tags.push(Tag::Phase(new_phase));
207 self.core.metadata.updated_at = Utc::now();
209 }
210
211 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 pub fn to_content(&self) -> Result<String, DocumentValidationError> {
221 let mut tera = Tera::default();
222
223 tera.add_raw_template("frontmatter", self.frontmatter_template())
225 .map_err(|e| {
226 DocumentValidationError::InvalidContent(format!("Template error: {}", e))
227 })?;
228
229 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 let tag_strings: Vec<String> = self.tags().iter().map(|tag| tag.to_str()).collect();
254 context.insert("tags", &tag_strings);
255
256 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 let frontmatter = tera.render("frontmatter", &context).map_err(|e| {
278 DocumentValidationError::InvalidContent(format!("Frontmatter render error: {}", e))
279 })?;
280
281 let content_body = &self.content().body;
283
284 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 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 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, (Todo, Active) => true,
330 (Active, Completed) => true,
331 (Active, Blocked) => true,
332 (Todo, Blocked) => true, (Blocked, Active) => true,
334 (Blocked, Todo) => true, _ => false,
336 }
337 } else {
338 false }
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 if self.title().trim().is_empty() {
353 return Err(DocumentValidationError::InvalidTitle(
354 "Task title cannot be empty".to_string(),
355 ));
356 }
357
358 if self.parent_id().is_none() {
360 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 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 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 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 match Self::next_phase_in_sequence(current_phase) {
434 Some(next) => next,
435 None => return Ok(current_phase), }
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 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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), vec![], 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 let task_no_parent = Task::new(
561 "Test Task".to_string(),
562 None, None, None, None, vec![], 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 let blocked_task = Task::new(
580 "Blocked Task".to_string(),
581 Some(DocumentId::from("parent-initiative")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), vec![], 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 let properly_blocked_task = Task::new(
596 "Blocked Task".to_string(),
597 Some(DocumentId::from("parent-initiative")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), 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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), 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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), 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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), 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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), vec![],
681 vec![Tag::Phase(Phase::Todo)],
682 false,
683 "TEST-T-0401".to_string(),
684 )
685 .expect("Failed to create task");
686
687 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 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 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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), 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 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 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 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); }
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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), vec![],
744 vec![Tag::Phase(Phase::Todo)],
745 false,
746 "TEST-T-0401".to_string(),
747 )
748 .expect("Failed to create task");
749
750 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 assert_eq!(task.phase().unwrap(), Phase::Todo);
763 }
764
765 #[test]
766 fn test_task_update_section() {
767 let mut task = Task::new(
769 "Test Task".to_string(),
770 Some(DocumentId::from("parent-initiative")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), vec![],
775 vec![Tag::Phase(Phase::Todo)],
776 false,
777 "TEST-T-0401".to_string(),
778 )
779 .expect("Failed to create task");
780
781 task.core_mut().content = DocumentContent::new(
783 "## Description\n\nOriginal description\n\n## Implementation Notes\n\nOriginal notes",
784 );
785
786 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 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 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}