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 Backlog => None, Todo => Some(Active),
164 Active => Some(Completed),
165 Completed => None, Blocked => None, _ => None, }
169 }
170
171 fn update_phase_tag(&mut self, new_phase: Phase) {
173 self.core.tags.retain(|tag| !matches!(tag, Tag::Phase(_)));
175 self.core.tags.push(Tag::Phase(new_phase));
177 self.core.metadata.updated_at = Utc::now();
179 }
180
181 pub async fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), DocumentValidationError> {
183 let content = self.to_content()?;
184 std::fs::write(path.as_ref(), content).map_err(|e| {
185 DocumentValidationError::InvalidContent(format!("Failed to write file: {}", e))
186 })
187 }
188
189 pub fn to_content(&self) -> Result<String, DocumentValidationError> {
191 let mut tera = Tera::default();
192
193 tera.add_raw_template("frontmatter", self.frontmatter_template())
195 .map_err(|e| {
196 DocumentValidationError::InvalidContent(format!("Template error: {}", e))
197 })?;
198
199 let mut context = Context::new();
201 context.insert("slug", &self.id().to_string());
202 context.insert("title", self.title());
203 context.insert("created_at", &self.metadata().created_at.to_rfc3339());
204 context.insert("updated_at", &self.metadata().updated_at.to_rfc3339());
205 context.insert("archived", &self.archived().to_string());
206 context.insert(
207 "exit_criteria_met",
208 &self.metadata().exit_criteria_met.to_string(),
209 );
210 context.insert(
211 "parent_id",
212 &self
213 .parent_id()
214 .map(|id| id.to_string())
215 .unwrap_or_default(),
216 );
217 let blocked_by_list: Vec<String> =
218 self.blocked_by().iter().map(|id| id.to_string()).collect();
219 context.insert("blocked_by", &blocked_by_list);
220
221 let tag_strings: Vec<String> = self.tags().iter().map(|tag| tag.to_str()).collect();
223 context.insert("tags", &tag_strings);
224
225 let frontmatter = tera.render("frontmatter", &context).map_err(|e| {
227 DocumentValidationError::InvalidContent(format!("Frontmatter render error: {}", e))
228 })?;
229
230 let content_body = &self.content().body;
232
233 let acceptance_criteria = if let Some(ac) = &self.content().acceptance_criteria {
235 format!("\n\n## Acceptance Criteria\n\n{}", ac)
236 } else {
237 String::new()
238 };
239
240 Ok(format!(
242 "---\n{}\n---\n\n{}{}",
243 frontmatter.trim_end(),
244 content_body,
245 acceptance_criteria
246 ))
247 }
248}
249
250impl Document for Task {
251 fn document_type(&self) -> DocumentType {
254 DocumentType::Task
255 }
256
257 fn title(&self) -> &str {
258 &self.core.title
259 }
260
261 fn metadata(&self) -> &DocumentMetadata {
262 &self.core.metadata
263 }
264
265 fn content(&self) -> &DocumentContent {
266 &self.core.content
267 }
268
269 fn core(&self) -> &super::traits::DocumentCore {
270 &self.core
271 }
272
273 fn can_transition_to(&self, phase: Phase) -> bool {
274 if let Ok(current_phase) = self.phase() {
275 use Phase::*;
276 match (current_phase, phase) {
277 (Backlog, Todo) => true, (Todo, Active) => true,
279 (Active, Completed) => true,
280 (Active, Blocked) => true,
281 (Todo, Blocked) => true, (Blocked, Active) => true,
283 (Blocked, Todo) => true, _ => false,
285 }
286 } else {
287 false }
289 }
290
291 fn parent_id(&self) -> Option<&DocumentId> {
292 self.core.parent_id.as_ref()
293 }
294
295 fn blocked_by(&self) -> &[DocumentId] {
296 &self.core.blocked_by
297 }
298
299 fn validate(&self) -> Result<(), DocumentValidationError> {
300 if self.title().trim().is_empty() {
302 return Err(DocumentValidationError::InvalidTitle(
303 "Task title cannot be empty".to_string(),
304 ));
305 }
306
307 if self.parent_id().is_none() {
309 if let Ok(phase) = self.phase() {
311 if phase != Phase::Backlog {
312 return Err(DocumentValidationError::MissingRequiredField(
313 "Tasks should have a parent Initiative unless in Backlog phase".to_string(),
314 ));
315 }
316 } else {
317 return Err(DocumentValidationError::MissingRequiredField(
318 "Tasks should have a parent Initiative".to_string(),
319 ));
320 }
321 }
322
323 if let Ok(Phase::Blocked) = self.phase() {
325 if self.blocked_by().is_empty() {
326 return Err(DocumentValidationError::InvalidContent(
327 "Blocked tasks must specify what they are blocked by".to_string(),
328 ));
329 }
330 }
331
332 Ok(())
333 }
334
335 fn exit_criteria_met(&self) -> bool {
336 false
340 }
341
342 fn template(&self) -> DocumentTemplate {
343 DocumentTemplate {
344 frontmatter: self.frontmatter_template(),
345 content: self.content_template(),
346 acceptance_criteria: self.acceptance_criteria_template(),
347 file_extension: "md",
348 }
349 }
350
351 fn frontmatter_template(&self) -> &'static str {
352 include_str!("frontmatter.yaml")
353 }
354
355 fn content_template(&self) -> &'static str {
356 include_str!("content.md")
357 }
358
359 fn acceptance_criteria_template(&self) -> &'static str {
360 include_str!("acceptance_criteria.md")
361 }
362
363 fn transition_phase(
364 &mut self,
365 target_phase: Option<Phase>,
366 ) -> Result<Phase, DocumentValidationError> {
367 let current_phase = self.phase()?;
368
369 let new_phase = match target_phase {
370 Some(phase) => {
371 if !self.can_transition_to(phase) {
373 return Err(DocumentValidationError::InvalidPhaseTransition {
374 from: current_phase,
375 to: phase,
376 });
377 }
378 phase
379 }
380 None => {
381 match Self::next_phase_in_sequence(current_phase) {
383 Some(next) => next,
384 None => return Ok(current_phase), }
386 }
387 };
388
389 self.update_phase_tag(new_phase);
390 Ok(new_phase)
391 }
392
393 fn core_mut(&mut self) -> &mut super::traits::DocumentCore {
394 &mut self.core
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use crate::domain::documents::traits::DocumentValidationError;
402 use tempfile::tempdir;
403
404 #[tokio::test]
405 async fn test_task_from_content() {
406 let content = r##"---
407id: test-task
408level: task
409title: "Test Task"
410created_at: 2025-01-01T00:00:00Z
411updated_at: 2025-01-01T00:00:00Z
412archived: false
413parent: initiative-001
414blocked_by: []
415
416tags:
417 - "#task"
418 - "#phase/todo"
419
420exit_criteria_met: false
421---
422
423# Test Task
424
425## Description
426
427This is a test task for our system.
428
429## Implementation Notes
430
431Details on how to implement this.
432
433## Acceptance Criteria
434
435- [ ] Implementation is complete
436- [ ] Tests pass
437"##;
438
439 let task = Task::from_content(content).unwrap();
440
441 assert_eq!(task.title(), "Test Task");
442 assert_eq!(task.document_type(), DocumentType::Task);
443 assert!(!task.archived());
444 assert_eq!(task.tags().len(), 2);
445 assert_eq!(task.phase().unwrap(), Phase::Todo);
446 assert!(task.content().has_acceptance_criteria());
447
448 let temp_dir = tempdir().unwrap();
450 let file_path = temp_dir.path().join("test-task.md");
451
452 task.to_file(&file_path).await.unwrap();
453 let loaded_task = Task::from_file(&file_path).await.unwrap();
454
455 assert_eq!(loaded_task.title(), task.title());
456 assert_eq!(loaded_task.phase().unwrap(), task.phase().unwrap());
457 assert_eq!(loaded_task.content().body, task.content().body);
458 assert_eq!(loaded_task.archived(), task.archived());
459 assert_eq!(loaded_task.tags().len(), task.tags().len());
460 }
461
462 #[test]
463 fn test_task_invalid_level() {
464 let content = r##"---
465id: test-doc
466level: strategy
467title: "Test Strategy"
468created_at: 2025-01-01T00:00:00Z
469updated_at: 2025-01-01T00:00:00Z
470archived: false
471tags:
472 - "#strategy"
473 - "#phase/shaping"
474exit_criteria_met: false
475---
476
477# Test Strategy
478"##;
479
480 let result = Task::from_content(content);
481 assert!(result.is_err());
482 match result.unwrap_err() {
483 DocumentValidationError::InvalidContent(msg) => {
484 assert!(msg.contains("Expected level 'task'"));
485 }
486 _ => panic!("Expected InvalidContent error"),
487 }
488 }
489
490 #[test]
491 fn test_task_validation() {
492 let task = Task::new(
493 "Test Task".to_string(),
494 Some(DocumentId::from("parent-initiative")),
495 Some("Parent Initiative".to_string()),
496 vec![],
497 vec![Tag::Label("task".to_string()), Tag::Phase(Phase::Todo)],
498 false,
499 )
500 .expect("Failed to create task");
501
502 assert!(task.validate().is_ok());
503
504 let task_no_parent = Task::new(
506 "Test Task".to_string(),
507 None, None,
509 vec![],
510 vec![Tag::Phase(Phase::Todo)],
511 false,
512 )
513 .expect("Failed to create task");
514
515 assert!(task_no_parent.validate().is_err());
516 }
517
518 #[test]
519 fn test_task_blocked_validation() {
520 let blocked_task = Task::new(
522 "Blocked Task".to_string(),
523 Some(DocumentId::from("parent-initiative")),
524 Some("Parent Initiative".to_string()),
525 vec![], vec![Tag::Phase(Phase::Blocked)],
527 false,
528 )
529 .expect("Failed to create task");
530
531 assert!(blocked_task.validate().is_err());
532
533 let properly_blocked_task = Task::new(
535 "Blocked Task".to_string(),
536 Some(DocumentId::from("parent-initiative")),
537 Some("Parent Initiative".to_string()),
538 vec![DocumentId::from("blocking-task")],
539 vec![Tag::Phase(Phase::Blocked)],
540 false,
541 )
542 .expect("Failed to create task");
543
544 assert!(properly_blocked_task.validate().is_ok());
545 }
546
547 #[test]
548 fn test_task_phase_transitions() {
549 let task = Task::new(
550 "Test Task".to_string(),
551 Some(DocumentId::from("parent-initiative")),
552 Some("Parent Initiative".to_string()),
553 vec![],
554 vec![Tag::Phase(Phase::Todo)],
555 false,
556 )
557 .expect("Failed to create task");
558
559 assert!(task.can_transition_to(Phase::Active));
560 assert!(task.can_transition_to(Phase::Blocked));
561 assert!(!task.can_transition_to(Phase::Completed));
562 assert!(!task.can_transition_to(Phase::Design));
563 }
564
565 #[test]
566 fn test_task_active_phase_transitions() {
567 let active_task = Task::new(
568 "Active Task".to_string(),
569 Some(DocumentId::from("parent-initiative")),
570 Some("Parent Initiative".to_string()),
571 vec![],
572 vec![Tag::Phase(Phase::Active)],
573 false,
574 )
575 .expect("Failed to create task");
576
577 assert!(active_task.can_transition_to(Phase::Completed));
578 assert!(active_task.can_transition_to(Phase::Blocked));
579 assert!(!active_task.can_transition_to(Phase::Todo));
580 }
581
582 #[test]
583 fn test_task_blocked_phase_transitions() {
584 let blocked_task = Task::new(
585 "Blocked Task".to_string(),
586 Some(DocumentId::from("parent-initiative")),
587 Some("Parent Initiative".to_string()),
588 vec![DocumentId::from("blocking-task")],
589 vec![Tag::Phase(Phase::Blocked)],
590 false,
591 )
592 .expect("Failed to create task");
593
594 assert!(blocked_task.can_transition_to(Phase::Active));
595 assert!(blocked_task.can_transition_to(Phase::Todo));
596 assert!(!blocked_task.can_transition_to(Phase::Completed));
597 }
598
599 #[test]
600 fn test_task_transition_phase_auto() {
601 let mut task = Task::new(
602 "Test Task".to_string(),
603 Some(DocumentId::from("parent-initiative")),
604 Some("Parent Initiative".to_string()),
605 vec![],
606 vec![Tag::Phase(Phase::Todo)],
607 false,
608 )
609 .expect("Failed to create task");
610
611 let new_phase = task.transition_phase(None).unwrap();
613 assert_eq!(new_phase, Phase::Active);
614 assert_eq!(task.phase().unwrap(), Phase::Active);
615
616 let new_phase = task.transition_phase(None).unwrap();
618 assert_eq!(new_phase, Phase::Completed);
619 assert_eq!(task.phase().unwrap(), Phase::Completed);
620
621 let new_phase = task.transition_phase(None).unwrap();
623 assert_eq!(new_phase, Phase::Completed);
624 assert_eq!(task.phase().unwrap(), Phase::Completed);
625 }
626
627 #[test]
628 fn test_task_transition_phase_blocking() {
629 let mut task = Task::new(
630 "Test Task".to_string(),
631 Some(DocumentId::from("parent-initiative")),
632 Some("Parent Initiative".to_string()),
633 vec![DocumentId::from("blocking-task")],
634 vec![Tag::Phase(Phase::Todo)],
635 false,
636 )
637 .expect("Failed to create task");
638
639 let new_phase = task.transition_phase(Some(Phase::Blocked)).unwrap();
641 assert_eq!(new_phase, Phase::Blocked);
642 assert_eq!(task.phase().unwrap(), Phase::Blocked);
643
644 let new_phase = task.transition_phase(Some(Phase::Active)).unwrap();
646 assert_eq!(new_phase, Phase::Active);
647 assert_eq!(task.phase().unwrap(), Phase::Active);
648
649 task.core.tags.retain(|tag| !matches!(tag, Tag::Phase(_)));
651 task.core.tags.push(Tag::Phase(Phase::Blocked));
652 let new_phase = task.transition_phase(None).unwrap();
653 assert_eq!(new_phase, Phase::Blocked); }
655
656 #[test]
657 fn test_task_transition_phase_invalid() {
658 let mut task = Task::new(
659 "Test Task".to_string(),
660 Some(DocumentId::from("parent-initiative")),
661 Some("Parent Initiative".to_string()),
662 vec![],
663 vec![Tag::Phase(Phase::Todo)],
664 false,
665 )
666 .expect("Failed to create task");
667
668 let result = task.transition_phase(Some(Phase::Completed));
670 assert!(result.is_err());
671 match result.unwrap_err() {
672 DocumentValidationError::InvalidPhaseTransition { from, to } => {
673 assert_eq!(from, Phase::Todo);
674 assert_eq!(to, Phase::Completed);
675 }
676 _ => panic!("Expected InvalidPhaseTransition error"),
677 }
678
679 assert_eq!(task.phase().unwrap(), Phase::Todo);
681 }
682
683 #[test]
684 fn test_task_update_section() {
685 let mut task = Task::new(
687 "Test Task".to_string(),
688 Some(DocumentId::from("parent-initiative")),
689 Some("Parent Initiative".to_string()),
690 vec![],
691 vec![Tag::Phase(Phase::Todo)],
692 false,
693 )
694 .expect("Failed to create task");
695
696 task.core_mut().content = DocumentContent::new(
698 "## Description\n\nOriginal description\n\n## Implementation Notes\n\nOriginal notes",
699 );
700
701 task.update_section("Updated task description", "Description", false)
703 .unwrap();
704 let content = task.content().body.clone();
705 assert!(content.contains("## Description\n\nUpdated task description"));
706 assert!(!content.contains("Original description"));
707
708 task.update_section(
710 "Additional implementation details",
711 "Implementation Notes",
712 true,
713 )
714 .unwrap();
715 let content = task.content().body.clone();
716 assert!(content.contains("Original notes"));
717 assert!(content.contains("Additional implementation details"));
718
719 task.update_section("Test approach details", "Testing Strategy", false)
721 .unwrap();
722 let content = task.content().body.clone();
723 assert!(content.contains("## Testing Strategy\n\nTest approach details"));
724 }
725}