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