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 DocumentType::Task.can_transition(current_phase, phase)
357 } else {
358 false }
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 if self.title().trim().is_empty() {
373 return Err(DocumentValidationError::InvalidTitle(
374 "Task title cannot be empty".to_string(),
375 ));
376 }
377
378 if self.parent_id().is_none() {
380 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 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 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 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 match Self::next_phase_in_sequence(current_phase) {
454 Some(next) => next,
455 None => return Ok(current_phase), }
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 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")), 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)],
572 false,
573 "TEST-T-0401".to_string(),
574 )
575 .expect("Failed to create task");
576
577 assert!(task.validate().is_ok());
578
579 let task_no_parent = Task::new(
581 "Test Task".to_string(),
582 None, None, None, None, vec![], 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 let blocked_task = Task::new(
600 "Blocked Task".to_string(),
601 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)],
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 let properly_blocked_task = Task::new(
616 "Blocked Task".to_string(),
617 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")],
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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), 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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), 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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), 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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), vec![],
701 vec![Tag::Phase(Phase::Todo)],
702 false,
703 "TEST-T-0401".to_string(),
704 )
705 .expect("Failed to create task");
706
707 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 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 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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), 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 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 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 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); }
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")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), vec![],
764 vec![Tag::Phase(Phase::Todo)],
765 false,
766 "TEST-T-0401".to_string(),
767 )
768 .expect("Failed to create task");
769
770 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 assert_eq!(task.phase().unwrap(), Phase::Todo);
783 }
784
785 #[test]
786 fn test_task_update_section() {
787 let mut task = Task::new(
789 "Test Task".to_string(),
790 Some(DocumentId::from("parent-initiative")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), vec![],
795 vec![Tag::Phase(Phase::Todo)],
796 false,
797 "TEST-T-0401".to_string(),
798 )
799 .expect("Failed to create task");
800
801 task.core_mut().content = DocumentContent::new(
803 "## Description\n\nOriginal description\n\n## Implementation Notes\n\nOriginal notes",
804 );
805
806 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 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 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}