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 pub fn new(
20 title: String,
21 parent_id: Option<DocumentId>, parent_title: Option<String>, strategy_id: Option<DocumentId>, initiative_id: Option<DocumentId>, blocked_by: Vec<DocumentId>,
26 tags: Vec<Tag>,
27 archived: bool,
28 ) -> Result<Self, DocumentValidationError> {
29 let metadata = DocumentMetadata::new();
31
32 let template_content = include_str!("content.md");
34 let mut tera = Tera::default();
35 tera.add_raw_template("task_content", template_content)
36 .map_err(|e| {
37 DocumentValidationError::InvalidContent(format!("Template error: {}", e))
38 })?;
39
40 let mut context = Context::new();
41 context.insert("title", &title);
42 context.insert(
43 "parent_title",
44 &parent_title.unwrap_or_else(|| "Parent Initiative".to_string()),
45 );
46
47 let rendered_content = tera.render("task_content", &context).map_err(|e| {
48 DocumentValidationError::InvalidContent(format!("Template render error: {}", e))
49 })?;
50
51 let content = DocumentContent::new(&rendered_content);
52
53 Ok(Self {
54 core: super::traits::DocumentCore {
55 title,
56 metadata,
57 content,
58 parent_id,
59 blocked_by,
60 tags,
61 archived,
62 strategy_id,
63 initiative_id,
64 },
65 })
66 }
67
68 pub fn from_parts(
70 title: String,
71 metadata: DocumentMetadata,
72 content: DocumentContent,
73 parent_id: Option<DocumentId>,
74 strategy_id: Option<DocumentId>,
75 initiative_id: Option<DocumentId>,
76 blocked_by: Vec<DocumentId>,
77 tags: Vec<Tag>,
78 archived: bool,
79 ) -> Self {
80 Self {
81 core: super::traits::DocumentCore {
82 title,
83 metadata,
84 content,
85 parent_id,
86 blocked_by,
87 tags,
88 archived,
89 strategy_id,
90 initiative_id,
91 },
92 }
93 }
94
95 pub async fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, DocumentValidationError> {
97 let raw_content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
98 DocumentValidationError::InvalidContent(format!("Failed to read file: {}", e))
99 })?;
100
101 Self::from_content(&raw_content)
102 }
103
104 pub fn from_content(raw_content: &str) -> Result<Self, DocumentValidationError> {
106 let parsed = gray_matter::Matter::<gray_matter::engine::YAML>::new().parse(raw_content);
108
109 let frontmatter = parsed.data.ok_or_else(|| {
111 DocumentValidationError::MissingRequiredField("frontmatter".to_string())
112 })?;
113
114 let fm_map = match frontmatter {
116 gray_matter::Pod::Hash(map) => map,
117 _ => {
118 return Err(DocumentValidationError::InvalidContent(
119 "Frontmatter must be a hash/map".to_string(),
120 ))
121 }
122 };
123
124 let title = FrontmatterParser::extract_string(&fm_map, "title")?;
126 let archived = FrontmatterParser::extract_bool(&fm_map, "archived").unwrap_or(false);
127
128 let created_at = FrontmatterParser::extract_datetime(&fm_map, "created_at")?;
130 let updated_at = FrontmatterParser::extract_datetime(&fm_map, "updated_at")?;
131 let exit_criteria_met =
132 FrontmatterParser::extract_bool(&fm_map, "exit_criteria_met").unwrap_or(false);
133
134 let tags = FrontmatterParser::extract_tags(&fm_map)?;
136
137 let level = FrontmatterParser::extract_string(&fm_map, "level")?;
139 if level != "task" {
140 return Err(DocumentValidationError::InvalidContent(format!(
141 "Expected level 'task', found '{}'",
142 level
143 )));
144 }
145
146 let parent_id = FrontmatterParser::extract_string(&fm_map, "parent")
148 .ok()
149 .map(DocumentId::from);
150 let blocked_by = FrontmatterParser::extract_string_array(&fm_map, "blocked_by")
151 .unwrap_or_default()
152 .into_iter()
153 .map(DocumentId::from)
154 .collect();
155
156 let metadata =
158 DocumentMetadata::from_frontmatter(created_at, updated_at, exit_criteria_met);
159 let content = DocumentContent::from_markdown(&parsed.content);
160
161 let strategy_id = FrontmatterParser::extract_optional_string(&fm_map, "strategy_id")
163 .map(DocumentId::from);
164 let initiative_id = FrontmatterParser::extract_optional_string(&fm_map, "initiative_id")
165 .map(DocumentId::from);
166
167 Ok(Self::from_parts(
168 title, metadata, content, parent_id, strategy_id, initiative_id, blocked_by, tags, archived,
169 ))
170 }
171
172 fn next_phase_in_sequence(current: Phase) -> Option<Phase> {
174 use Phase::*;
175 match current {
176 Backlog => None, Todo => Some(Active),
178 Active => Some(Completed),
179 Completed => None, Blocked => None, _ => None, }
183 }
184
185 fn update_phase_tag(&mut self, new_phase: Phase) {
187 self.core.tags.retain(|tag| !matches!(tag, Tag::Phase(_)));
189 self.core.tags.push(Tag::Phase(new_phase));
191 self.core.metadata.updated_at = Utc::now();
193 }
194
195 pub async fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), DocumentValidationError> {
197 let content = self.to_content()?;
198 std::fs::write(path.as_ref(), content).map_err(|e| {
199 DocumentValidationError::InvalidContent(format!("Failed to write file: {}", e))
200 })
201 }
202
203 pub fn to_content(&self) -> Result<String, DocumentValidationError> {
205 let mut tera = Tera::default();
206
207 tera.add_raw_template("frontmatter", self.frontmatter_template())
209 .map_err(|e| {
210 DocumentValidationError::InvalidContent(format!("Template error: {}", e))
211 })?;
212
213 let mut context = Context::new();
215 context.insert("slug", &self.id().to_string());
216 context.insert("title", self.title());
217 context.insert("created_at", &self.metadata().created_at.to_rfc3339());
218 context.insert("updated_at", &self.metadata().updated_at.to_rfc3339());
219 context.insert("archived", &self.archived().to_string());
220 context.insert(
221 "exit_criteria_met",
222 &self.metadata().exit_criteria_met.to_string(),
223 );
224 context.insert(
225 "parent_id",
226 &self
227 .parent_id()
228 .map(|id| id.to_string())
229 .unwrap_or_default(),
230 );
231 let blocked_by_list: Vec<String> =
232 self.blocked_by().iter().map(|id| id.to_string()).collect();
233 context.insert("blocked_by", &blocked_by_list);
234
235 let tag_strings: Vec<String> = self.tags().iter().map(|tag| tag.to_str()).collect();
237 context.insert("tags", &tag_strings);
238
239 context.insert(
241 "strategy_id",
242 &self.core.strategy_id
243 .as_ref()
244 .map(|id| id.to_string())
245 .unwrap_or_default(),
246 );
247 context.insert(
248 "initiative_id",
249 &self.core.initiative_id
250 .as_ref()
251 .map(|id| id.to_string())
252 .unwrap_or_default(),
253 );
254
255 let frontmatter = tera.render("frontmatter", &context).map_err(|e| {
257 DocumentValidationError::InvalidContent(format!("Frontmatter render error: {}", e))
258 })?;
259
260 let content_body = &self.content().body;
262
263 let acceptance_criteria = if let Some(ac) = &self.content().acceptance_criteria {
265 format!("\n\n## Acceptance Criteria\n\n{}", ac)
266 } else {
267 String::new()
268 };
269
270 Ok(format!(
272 "---\n{}\n---\n\n{}{}",
273 frontmatter.trim_end(),
274 content_body,
275 acceptance_criteria
276 ))
277 }
278}
279
280impl Document for Task {
281 fn document_type(&self) -> DocumentType {
284 DocumentType::Task
285 }
286
287 fn title(&self) -> &str {
288 &self.core.title
289 }
290
291 fn metadata(&self) -> &DocumentMetadata {
292 &self.core.metadata
293 }
294
295 fn content(&self) -> &DocumentContent {
296 &self.core.content
297 }
298
299 fn core(&self) -> &super::traits::DocumentCore {
300 &self.core
301 }
302
303 fn can_transition_to(&self, phase: Phase) -> bool {
304 if let Ok(current_phase) = self.phase() {
305 use Phase::*;
306 match (current_phase, phase) {
307 (Backlog, Todo) => true, (Todo, Active) => true,
309 (Active, Completed) => true,
310 (Active, Blocked) => true,
311 (Todo, Blocked) => true, (Blocked, Active) => true,
313 (Blocked, Todo) => true, _ => false,
315 }
316 } else {
317 false }
319 }
320
321 fn parent_id(&self) -> Option<&DocumentId> {
322 self.core.parent_id.as_ref()
323 }
324
325 fn blocked_by(&self) -> &[DocumentId] {
326 &self.core.blocked_by
327 }
328
329 fn validate(&self) -> Result<(), DocumentValidationError> {
330 if self.title().trim().is_empty() {
332 return Err(DocumentValidationError::InvalidTitle(
333 "Task title cannot be empty".to_string(),
334 ));
335 }
336
337 if self.parent_id().is_none() {
339 if let Ok(phase) = self.phase() {
341 if phase != Phase::Backlog {
342 return Err(DocumentValidationError::MissingRequiredField(
343 "Tasks should have a parent Initiative unless in Backlog phase".to_string(),
344 ));
345 }
346 } else {
347 return Err(DocumentValidationError::MissingRequiredField(
348 "Tasks should have a parent Initiative".to_string(),
349 ));
350 }
351 }
352
353 if let Ok(Phase::Blocked) = self.phase() {
355 if self.blocked_by().is_empty() {
356 return Err(DocumentValidationError::InvalidContent(
357 "Blocked tasks must specify what they are blocked by".to_string(),
358 ));
359 }
360 }
361
362 Ok(())
363 }
364
365 fn exit_criteria_met(&self) -> bool {
366 false
370 }
371
372 fn template(&self) -> DocumentTemplate {
373 DocumentTemplate {
374 frontmatter: self.frontmatter_template(),
375 content: self.content_template(),
376 acceptance_criteria: self.acceptance_criteria_template(),
377 file_extension: "md",
378 }
379 }
380
381 fn frontmatter_template(&self) -> &'static str {
382 include_str!("frontmatter.yaml")
383 }
384
385 fn content_template(&self) -> &'static str {
386 include_str!("content.md")
387 }
388
389 fn acceptance_criteria_template(&self) -> &'static str {
390 include_str!("acceptance_criteria.md")
391 }
392
393 fn transition_phase(
394 &mut self,
395 target_phase: Option<Phase>,
396 ) -> Result<Phase, DocumentValidationError> {
397 let current_phase = self.phase()?;
398
399 let new_phase = match target_phase {
400 Some(phase) => {
401 if !self.can_transition_to(phase) {
403 return Err(DocumentValidationError::InvalidPhaseTransition {
404 from: current_phase,
405 to: phase,
406 });
407 }
408 phase
409 }
410 None => {
411 match Self::next_phase_in_sequence(current_phase) {
413 Some(next) => next,
414 None => return Ok(current_phase), }
416 }
417 };
418
419 self.update_phase_tag(new_phase);
420 Ok(new_phase)
421 }
422
423 fn core_mut(&mut self) -> &mut super::traits::DocumentCore {
424 &mut self.core
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use crate::domain::documents::traits::DocumentValidationError;
432 use tempfile::tempdir;
433
434 #[tokio::test]
435 async fn test_task_from_content() {
436 let content = r##"---
437id: test-task
438level: task
439title: "Test Task"
440created_at: 2025-01-01T00:00:00Z
441updated_at: 2025-01-01T00:00:00Z
442archived: false
443parent: initiative-001
444blocked_by: []
445
446tags:
447 - "#task"
448 - "#phase/todo"
449
450exit_criteria_met: false
451---
452
453# Test Task
454
455## Description
456
457This is a test task for our system.
458
459## Implementation Notes
460
461Details on how to implement this.
462
463## Acceptance Criteria
464
465- [ ] Implementation is complete
466- [ ] Tests pass
467"##;
468
469 let task = Task::from_content(content).unwrap();
470
471 assert_eq!(task.title(), "Test Task");
472 assert_eq!(task.document_type(), DocumentType::Task);
473 assert!(!task.archived());
474 assert_eq!(task.tags().len(), 2);
475 assert_eq!(task.phase().unwrap(), Phase::Todo);
476 assert!(task.content().has_acceptance_criteria());
477
478 let temp_dir = tempdir().unwrap();
480 let file_path = temp_dir.path().join("test-task.md");
481
482 task.to_file(&file_path).await.unwrap();
483 let loaded_task = Task::from_file(&file_path).await.unwrap();
484
485 assert_eq!(loaded_task.title(), task.title());
486 assert_eq!(loaded_task.phase().unwrap(), task.phase().unwrap());
487 assert_eq!(loaded_task.content().body, task.content().body);
488 assert_eq!(loaded_task.archived(), task.archived());
489 assert_eq!(loaded_task.tags().len(), task.tags().len());
490 }
491
492 #[test]
493 fn test_task_invalid_level() {
494 let content = r##"---
495id: test-doc
496level: strategy
497title: "Test Strategy"
498created_at: 2025-01-01T00:00:00Z
499updated_at: 2025-01-01T00:00:00Z
500archived: false
501tags:
502 - "#strategy"
503 - "#phase/shaping"
504exit_criteria_met: false
505---
506
507# Test Strategy
508"##;
509
510 let result = Task::from_content(content);
511 assert!(result.is_err());
512 match result.unwrap_err() {
513 DocumentValidationError::InvalidContent(msg) => {
514 assert!(msg.contains("Expected level 'task'"));
515 }
516 _ => panic!("Expected InvalidContent error"),
517 }
518 }
519
520 #[test]
521 fn test_task_validation() {
522 let task = Task::new(
523 "Test Task".to_string(),
524 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)],
530 false,
531 )
532 .expect("Failed to create task");
533
534 assert!(task.validate().is_ok());
535
536 let task_no_parent = Task::new(
538 "Test Task".to_string(),
539 None, None, None, None, vec![], vec![Tag::Phase(Phase::Todo)],
545 false,
546 )
547 .expect("Failed to create task");
548
549 assert!(task_no_parent.validate().is_err());
550 }
551
552 #[test]
553 fn test_task_blocked_validation() {
554 let blocked_task = Task::new(
556 "Blocked Task".to_string(),
557 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)],
563 false,
564 )
565 .expect("Failed to create task");
566
567 assert!(blocked_task.validate().is_err());
568
569 let properly_blocked_task = Task::new(
571 "Blocked Task".to_string(),
572 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")],
577 vec![Tag::Phase(Phase::Blocked)],
578 false,
579 )
580 .expect("Failed to create task");
581
582 assert!(properly_blocked_task.validate().is_ok());
583 }
584
585 #[test]
586 fn test_task_phase_transitions() {
587 let task = Task::new(
588 "Test Task".to_string(),
589 Some(DocumentId::from("parent-initiative")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), vec![],
594 vec![Tag::Phase(Phase::Todo)],
595 false,
596 )
597 .expect("Failed to create task");
598
599 assert!(task.can_transition_to(Phase::Active));
600 assert!(task.can_transition_to(Phase::Blocked));
601 assert!(!task.can_transition_to(Phase::Completed));
602 assert!(!task.can_transition_to(Phase::Design));
603 }
604
605 #[test]
606 fn test_task_active_phase_transitions() {
607 let active_task = Task::new(
608 "Active Task".to_string(),
609 Some(DocumentId::from("parent-initiative")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), vec![],
614 vec![Tag::Phase(Phase::Active)],
615 false,
616 )
617 .expect("Failed to create task");
618
619 assert!(active_task.can_transition_to(Phase::Completed));
620 assert!(active_task.can_transition_to(Phase::Blocked));
621 assert!(!active_task.can_transition_to(Phase::Todo));
622 }
623
624 #[test]
625 fn test_task_blocked_phase_transitions() {
626 let blocked_task = Task::new(
627 "Blocked Task".to_string(),
628 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")],
633 vec![Tag::Phase(Phase::Blocked)],
634 false,
635 )
636 .expect("Failed to create task");
637
638 assert!(blocked_task.can_transition_to(Phase::Active));
639 assert!(blocked_task.can_transition_to(Phase::Todo));
640 assert!(!blocked_task.can_transition_to(Phase::Completed));
641 }
642
643 #[test]
644 fn test_task_transition_phase_auto() {
645 let mut task = Task::new(
646 "Test Task".to_string(),
647 Some(DocumentId::from("parent-initiative")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), vec![],
652 vec![Tag::Phase(Phase::Todo)],
653 false,
654 )
655 .expect("Failed to create task");
656
657 let new_phase = task.transition_phase(None).unwrap();
659 assert_eq!(new_phase, Phase::Active);
660 assert_eq!(task.phase().unwrap(), Phase::Active);
661
662 let new_phase = task.transition_phase(None).unwrap();
664 assert_eq!(new_phase, Phase::Completed);
665 assert_eq!(task.phase().unwrap(), Phase::Completed);
666
667 let new_phase = task.transition_phase(None).unwrap();
669 assert_eq!(new_phase, Phase::Completed);
670 assert_eq!(task.phase().unwrap(), Phase::Completed);
671 }
672
673 #[test]
674 fn test_task_transition_phase_blocking() {
675 let mut task = Task::new(
676 "Test Task".to_string(),
677 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")],
682 vec![Tag::Phase(Phase::Todo)],
683 false,
684 )
685 .expect("Failed to create task");
686
687 let new_phase = task.transition_phase(Some(Phase::Blocked)).unwrap();
689 assert_eq!(new_phase, Phase::Blocked);
690 assert_eq!(task.phase().unwrap(), Phase::Blocked);
691
692 let new_phase = task.transition_phase(Some(Phase::Active)).unwrap();
694 assert_eq!(new_phase, Phase::Active);
695 assert_eq!(task.phase().unwrap(), Phase::Active);
696
697 task.core.tags.retain(|tag| !matches!(tag, Tag::Phase(_)));
699 task.core.tags.push(Tag::Phase(Phase::Blocked));
700 let new_phase = task.transition_phase(None).unwrap();
701 assert_eq!(new_phase, Phase::Blocked); }
703
704 #[test]
705 fn test_task_transition_phase_invalid() {
706 let mut task = Task::new(
707 "Test Task".to_string(),
708 Some(DocumentId::from("parent-initiative")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), vec![],
713 vec![Tag::Phase(Phase::Todo)],
714 false,
715 )
716 .expect("Failed to create task");
717
718 let result = task.transition_phase(Some(Phase::Completed));
720 assert!(result.is_err());
721 match result.unwrap_err() {
722 DocumentValidationError::InvalidPhaseTransition { from, to } => {
723 assert_eq!(from, Phase::Todo);
724 assert_eq!(to, Phase::Completed);
725 }
726 _ => panic!("Expected InvalidPhaseTransition error"),
727 }
728
729 assert_eq!(task.phase().unwrap(), Phase::Todo);
731 }
732
733 #[test]
734 fn test_task_update_section() {
735 let mut task = Task::new(
737 "Test Task".to_string(),
738 Some(DocumentId::from("parent-initiative")), Some("Parent Initiative".to_string()), Some(DocumentId::from("parent-strategy")), Some(DocumentId::from("parent-initiative")), vec![],
743 vec![Tag::Phase(Phase::Todo)],
744 false,
745 )
746 .expect("Failed to create task");
747
748 task.core_mut().content = DocumentContent::new(
750 "## Description\n\nOriginal description\n\n## Implementation Notes\n\nOriginal notes",
751 );
752
753 task.update_section("Updated task description", "Description", false)
755 .unwrap();
756 let content = task.content().body.clone();
757 assert!(content.contains("## Description\n\nUpdated task description"));
758 assert!(!content.contains("Original description"));
759
760 task.update_section(
762 "Additional implementation details",
763 "Implementation Notes",
764 true,
765 )
766 .unwrap();
767 let content = task.content().body.clone();
768 assert!(content.contains("Original notes"));
769 assert!(content.contains("Additional implementation details"));
770
771 task.update_section("Test approach details", "Testing Strategy", false)
773 .unwrap();
774 let content = task.content().body.clone();
775 assert!(content.contains("## Testing Strategy\n\nTest approach details"));
776 }
777}