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 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 #[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 let metadata = DocumentMetadata::new(short_code);
63
64 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 #[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 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 pub fn from_content(raw_content: &str) -> Result<Self, DocumentValidationError> {
138 let parsed = gray_matter::Matter::<gray_matter::engine::YAML>::new().parse(raw_content);
140
141 let frontmatter = parsed.data.ok_or_else(|| {
143 DocumentValidationError::MissingRequiredField("frontmatter".to_string())
144 })?;
145
146 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 let title = FrontmatterParser::extract_string(&fm_map, "title")?;
158 let archived = FrontmatterParser::extract_bool(&fm_map, "archived").unwrap_or(false);
159
160 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 let tags = FrontmatterParser::extract_tags(&fm_map)?;
168
169 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 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 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 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 fn next_phase_in_sequence(current: Phase) -> Option<Phase> {
219 use Phase::*;
220 match current {
221 Backlog => None, Todo => Some(Active),
223 Active => Some(Completed),
224 Completed => None, Blocked => None, _ => None, }
228 }
229
230 fn update_phase_tag(&mut self, new_phase: Phase) {
232 self.core.tags.retain(|tag| !matches!(tag, Tag::Phase(_)));
234 self.core.tags.push(Tag::Phase(new_phase));
236 self.core.metadata.updated_at = Utc::now();
238 }
239
240 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 pub fn to_content(&self) -> Result<String, DocumentValidationError> {
250 let mut tera = Tera::default();
251
252 tera.add_raw_template("frontmatter", self.frontmatter_template())
254 .map_err(|e| {
255 DocumentValidationError::InvalidContent(format!("Template error: {}", e))
256 })?;
257
258 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 let tag_strings: Vec<String> = self.tags().iter().map(|tag| tag.to_str()).collect();
283 context.insert("tags", &tag_strings);
284
285 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 let frontmatter = tera.render("frontmatter", &context).map_err(|e| {
307 DocumentValidationError::InvalidContent(format!("Frontmatter render error: {}", e))
308 })?;
309
310 let content_body = &self.content().body;
312
313 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 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 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, (Todo, Active) => true,
359 (Active, Completed) => true,
360 (Active, Blocked) => true,
361 (Todo, Blocked) => true, (Blocked, Active) => true,
363 (Blocked, Todo) => true, _ => false,
365 }
366 } else {
367 false }
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 if self.title().trim().is_empty() {
382 return Err(DocumentValidationError::InvalidTitle(
383 "Task title cannot be empty".to_string(),
384 ));
385 }
386
387 if self.parent_id().is_none() {
389 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 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 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 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 match Self::next_phase_in_sequence(current_phase) {
463 Some(next) => next,
464 None => return Ok(current_phase), }
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 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")), 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)],
581 false,
582 "TEST-T-0401".to_string(),
583 )
584 .expect("Failed to create task");
585
586 assert!(task.validate().is_ok());
587
588 let task_no_parent = Task::new(
590 "Test Task".to_string(),
591 None, None, None, None, vec![], 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 let blocked_task = Task::new(
609 "Blocked Task".to_string(),
610 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)],
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 let properly_blocked_task = Task::new(
625 "Blocked Task".to_string(),
626 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")],
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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), 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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), 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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), 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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), vec![],
710 vec![Tag::Phase(Phase::Todo)],
711 false,
712 "TEST-T-0401".to_string(),
713 )
714 .expect("Failed to create task");
715
716 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 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 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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), 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 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 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 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); }
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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), vec![],
773 vec![Tag::Phase(Phase::Todo)],
774 false,
775 "TEST-T-0401".to_string(),
776 )
777 .expect("Failed to create task");
778
779 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 assert_eq!(task.phase().unwrap(), Phase::Todo);
792 }
793
794 #[test]
795 fn test_task_update_section() {
796 let mut task = Task::new(
798 "Test Task".to_string(),
799 Some(DocumentId::from("parent-initiative")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), vec![],
804 vec![Tag::Phase(Phase::Todo)],
805 false,
806 "TEST-T-0401".to_string(),
807 )
808 .expect("Failed to create task");
809
810 task.core_mut().content = DocumentContent::new(
812 "## Description\n\nOriginal description\n\n## Implementation Notes\n\nOriginal notes",
813 );
814
815 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 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 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}