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 gray_matter;
7use std::path::Path;
8use tera::{Context, Tera};
9
10#[derive(Debug)]
12pub struct Vision {
13 core: super::traits::DocumentCore,
14}
15
16impl Vision {
17 pub fn new(
19 title: String,
20 tags: Vec<Tag>,
21 archived: bool,
22 ) -> Result<Self, DocumentValidationError> {
23 let metadata = DocumentMetadata::new();
25
26 let template_content = include_str!("content.md");
28 let mut tera = Tera::default();
29 tera.add_raw_template("vision_content", template_content)
30 .map_err(|e| {
31 DocumentValidationError::InvalidContent(format!("Template error: {}", e))
32 })?;
33
34 let mut context = Context::new();
35 context.insert("title", &title);
36
37 let rendered_content = tera.render("vision_content", &context).map_err(|e| {
38 DocumentValidationError::InvalidContent(format!("Template render error: {}", e))
39 })?;
40
41 let content = DocumentContent::new(&rendered_content);
42
43 Ok(Self {
44 core: super::traits::DocumentCore {
45 title,
46 metadata,
47 content,
48 parent_id: None, blocked_by: Vec::new(), tags,
51 archived,
52 },
53 })
54 }
55
56 pub fn from_parts(
58 title: String,
59 metadata: DocumentMetadata,
60 content: DocumentContent,
61 tags: Vec<Tag>,
62 archived: bool,
63 ) -> Self {
64 Self {
65 core: super::traits::DocumentCore {
66 title,
67 metadata,
68 content,
69 parent_id: None, blocked_by: Vec::new(), tags,
72 archived,
73 },
74 }
75 }
76
77 pub async fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, DocumentValidationError> {
79 let raw_content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
80 DocumentValidationError::InvalidContent(format!("Failed to read file: {}", e))
81 })?;
82
83 Self::from_content(&raw_content)
84 }
85
86 pub fn from_content(raw_content: &str) -> Result<Self, DocumentValidationError> {
88 let parsed = gray_matter::Matter::<gray_matter::engine::YAML>::new().parse(raw_content);
90
91 let frontmatter = parsed.data.ok_or_else(|| {
93 DocumentValidationError::MissingRequiredField("frontmatter".to_string())
94 })?;
95
96 let fm_map = match frontmatter {
98 gray_matter::Pod::Hash(map) => map,
99 _ => {
100 return Err(DocumentValidationError::InvalidContent(
101 "Frontmatter must be a hash/map".to_string(),
102 ))
103 }
104 };
105
106 let title = FrontmatterParser::extract_string(&fm_map, "title")?;
108 let archived = FrontmatterParser::extract_bool(&fm_map, "archived").unwrap_or(false);
109
110 let created_at = FrontmatterParser::extract_datetime(&fm_map, "created_at")?;
112 let updated_at = FrontmatterParser::extract_datetime(&fm_map, "updated_at")?;
113 let exit_criteria_met =
114 FrontmatterParser::extract_bool(&fm_map, "exit_criteria_met").unwrap_or(false);
115
116 let tags = FrontmatterParser::extract_tags(&fm_map)?;
118
119 let level = FrontmatterParser::extract_string(&fm_map, "level")?;
121 if level != "vision" {
122 return Err(DocumentValidationError::InvalidContent(format!(
123 "Expected level 'vision', found '{}'",
124 level
125 )));
126 }
127
128 let metadata =
130 DocumentMetadata::from_frontmatter(created_at, updated_at, exit_criteria_met);
131 let content = DocumentContent::from_markdown(&parsed.content);
132
133 Ok(Self::from_parts(title, metadata, content, tags, archived))
134 }
135
136 fn next_phase_in_sequence(current: Phase) -> Option<Phase> {
138 use Phase::*;
139 match current {
140 Draft => Some(Review),
141 Review => Some(Published),
142 Published => None, _ => None, }
145 }
146
147 fn update_phase_tag(&mut self, new_phase: Phase) {
149 self.core.tags.retain(|tag| !matches!(tag, Tag::Phase(_)));
151 self.core.tags.push(Tag::Phase(new_phase));
153 self.core.metadata.updated_at = chrono::Utc::now();
155 }
156
157 pub async fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), DocumentValidationError> {
159 let content = self.to_content()?;
160 std::fs::write(path.as_ref(), content).map_err(|e| {
161 DocumentValidationError::InvalidContent(format!("Failed to write file: {}", e))
162 })
163 }
164
165 pub fn to_content(&self) -> Result<String, DocumentValidationError> {
167 let mut tera = Tera::default();
168
169 tera.add_raw_template("frontmatter", self.frontmatter_template())
171 .map_err(|e| {
172 DocumentValidationError::InvalidContent(format!("Template error: {}", e))
173 })?;
174
175 let mut context = Context::new();
177 context.insert("slug", &self.id().to_string());
178 context.insert("title", self.title());
179 context.insert("created_at", &self.metadata().created_at.to_rfc3339());
180 context.insert("updated_at", &self.metadata().updated_at.to_rfc3339());
181 context.insert("archived", &self.archived().to_string());
182 context.insert(
183 "exit_criteria_met",
184 &self.metadata().exit_criteria_met.to_string(),
185 );
186
187 let tag_strings: Vec<String> = self.tags().iter().map(|tag| tag.to_str()).collect();
189 context.insert("tags", &tag_strings);
190
191 let frontmatter = tera.render("frontmatter", &context).map_err(|e| {
193 DocumentValidationError::InvalidContent(format!("Frontmatter render error: {}", e))
194 })?;
195
196 let content_body = &self.content().body;
198
199 let acceptance_criteria = if let Some(ac) = &self.content().acceptance_criteria {
201 format!("\n\n## Acceptance Criteria\n\n{}", ac)
202 } else {
203 String::new()
204 };
205
206 Ok(format!(
208 "---\n{}\n---\n\n{}{}",
209 frontmatter.trim_end(),
210 content_body,
211 acceptance_criteria
212 ))
213 }
214}
215
216impl Document for Vision {
217 fn document_type(&self) -> DocumentType {
220 DocumentType::Vision
221 }
222
223 fn title(&self) -> &str {
224 &self.core.title
225 }
226
227 fn metadata(&self) -> &DocumentMetadata {
228 &self.core.metadata
229 }
230
231 fn content(&self) -> &DocumentContent {
232 &self.core.content
233 }
234
235 fn core(&self) -> &super::traits::DocumentCore {
236 &self.core
237 }
238
239 fn can_transition_to(&self, phase: Phase) -> bool {
240 if let Ok(current_phase) = self.phase() {
241 use Phase::*;
242 matches!(
243 (current_phase, phase),
244 (Draft, Review) | (Review, Published)
245 )
246 } else {
247 false }
249 }
250
251 fn parent_id(&self) -> Option<&DocumentId> {
252 None }
254
255 fn blocked_by(&self) -> &[DocumentId] {
256 &[] }
258
259 fn validate(&self) -> Result<(), DocumentValidationError> {
260 if self.title().trim().is_empty() {
262 return Err(DocumentValidationError::InvalidTitle(
263 "Vision title cannot be empty".to_string(),
264 ));
265 }
266
267 if self.parent_id().is_some() {
268 return Err(DocumentValidationError::InvalidParent(
269 "Visions cannot have parents".to_string(),
270 ));
271 }
272
273 if !self.blocked_by().is_empty() {
274 return Err(DocumentValidationError::InvalidContent(
275 "Visions cannot be blocked by other documents".to_string(),
276 ));
277 }
278
279 Ok(())
280 }
281
282 fn exit_criteria_met(&self) -> bool {
283 false
287 }
288
289 fn template(&self) -> DocumentTemplate {
290 DocumentTemplate {
291 frontmatter: self.frontmatter_template(),
292 content: self.content_template(),
293 acceptance_criteria: self.acceptance_criteria_template(),
294 file_extension: "md",
295 }
296 }
297
298 fn frontmatter_template(&self) -> &'static str {
299 include_str!("frontmatter.yaml")
300 }
301
302 fn content_template(&self) -> &'static str {
303 include_str!("content.md")
304 }
305
306 fn acceptance_criteria_template(&self) -> &'static str {
307 include_str!("acceptance_criteria.md")
308 }
309
310 fn transition_phase(
311 &mut self,
312 target_phase: Option<Phase>,
313 ) -> Result<Phase, DocumentValidationError> {
314 let current_phase = self.phase()?;
315
316 let new_phase = match target_phase {
317 Some(phase) => {
318 if !self.can_transition_to(phase) {
320 return Err(DocumentValidationError::InvalidPhaseTransition {
321 from: current_phase,
322 to: phase,
323 });
324 }
325 phase
326 }
327 None => {
328 match Self::next_phase_in_sequence(current_phase) {
330 Some(next) => next,
331 None => return Ok(current_phase), }
333 }
334 };
335
336 self.update_phase_tag(new_phase);
337 Ok(new_phase)
338 }
339
340 fn core_mut(&mut self) -> &mut super::traits::DocumentCore {
341 &mut self.core
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::domain::documents::traits::DocumentValidationError;
349 use tempfile::tempdir;
350
351 #[tokio::test]
352 async fn test_vision_from_content() {
353 let content = r##"---
354id: test-vision
355level: vision
356title: "Test Vision"
357created_at: 2025-01-01T00:00:00Z
358updated_at: 2025-01-01T00:00:00Z
359archived: false
360
361tags:
362 - "#vision"
363 - "#phase/draft"
364
365exit_criteria_met: false
366---
367
368# Test Vision
369
370## Purpose
371
372This is a test vision for our system.
373
374## Current State
375
376We are here.
377
378## Future State
379
380We want to be there.
381
382## Acceptance Criteria
383
384- [ ] Purpose is clearly defined
385- [ ] Current and future states are documented
386"##;
387
388 let vision = Vision::from_content(content).unwrap();
389
390 assert_eq!(vision.title(), "Test Vision");
391 assert_eq!(vision.document_type(), DocumentType::Vision);
392 assert!(!vision.archived());
393 assert_eq!(vision.tags().len(), 2);
394 assert_eq!(vision.phase().unwrap(), Phase::Draft);
395 assert!(vision.content().has_acceptance_criteria());
396
397 let temp_dir = tempdir().unwrap();
399 let file_path = temp_dir.path().join("test-vision.md");
400
401 vision.to_file(&file_path).await.unwrap();
402 let loaded_vision = Vision::from_file(&file_path).await.unwrap();
403
404 assert_eq!(loaded_vision.title(), vision.title());
405 assert_eq!(loaded_vision.phase().unwrap(), vision.phase().unwrap());
406 assert_eq!(loaded_vision.content().body, vision.content().body);
407 assert_eq!(loaded_vision.archived(), vision.archived());
408 assert_eq!(loaded_vision.tags().len(), vision.tags().len());
409 }
410
411 #[test]
412 fn test_vision_invalid_level() {
413 let content = r##"---
414id: test-doc
415level: strategy
416title: "Test Strategy"
417created_at: 2025-01-01T00:00:00Z
418updated_at: 2025-01-01T00:00:00Z
419archived: false
420tags:
421 - "#strategy"
422 - "#phase/shaping"
423exit_criteria_met: false
424---
425
426# Test Strategy
427"##;
428
429 let result = Vision::from_content(content);
430 assert!(result.is_err());
431 match result.unwrap_err() {
432 DocumentValidationError::InvalidContent(msg) => {
433 assert!(msg.contains("Expected level 'vision'"));
434 }
435 _ => panic!("Expected InvalidContent error"),
436 }
437 }
438
439 #[test]
440 fn test_vision_missing_title() {
441 let content = r##"---
442id: test-vision
443level: vision
444created_at: 2025-01-01T00:00:00Z
445updated_at: 2025-01-01T00:00:00Z
446archived: false
447tags:
448 - "#vision"
449 - "#phase/draft"
450exit_criteria_met: false
451---
452
453Some content without a title in frontmatter.
454"##;
455
456 let result = Vision::from_content(content);
457 assert!(result.is_err());
458 match result.unwrap_err() {
459 DocumentValidationError::MissingRequiredField(field) => {
460 assert_eq!(field, "title");
461 }
462 _ => panic!("Expected MissingRequiredField error"),
463 }
464 }
465
466 #[tokio::test]
467 async fn test_vision_tag_parsing() {
468 let content = r##"---
469id: test-vision
470level: vision
471title: "Test Vision"
472created_at: 2025-01-01T00:00:00Z
473updated_at: 2025-01-01T00:00:00Z
474archived: false
475tags:
476 - "#vision"
477 - "#phase/review"
478 - "#high-priority"
479 - "urgent"
480exit_criteria_met: false
481---
482
483# Test Vision
484"##;
485
486 let vision = Vision::from_content(content).unwrap();
487 let tags = vision.tags();
488
489 assert_eq!(tags.len(), 4);
490 assert!(tags.contains(&Tag::Label("vision".to_string())));
491 assert!(tags.contains(&Tag::Phase(Phase::Review)));
492 assert!(tags.contains(&Tag::Label("high-priority".to_string())));
493 assert!(tags.contains(&Tag::Label("urgent".to_string())));
494
495 assert_eq!(vision.phase().unwrap(), Phase::Review);
496
497 let temp_dir = tempdir().unwrap();
499 let file_path = temp_dir.path().join("test-vision.md");
500
501 vision.to_file(&file_path).await.unwrap();
502 let loaded_vision = Vision::from_file(&file_path).await.unwrap();
503
504 assert_eq!(loaded_vision.title(), vision.title());
505 assert_eq!(loaded_vision.phase().unwrap(), vision.phase().unwrap());
506 assert_eq!(loaded_vision.tags().len(), vision.tags().len());
507
508 let loaded_tags = loaded_vision.tags();
510 assert!(loaded_tags.contains(&Tag::Label("vision".to_string())));
511 assert!(loaded_tags.contains(&Tag::Phase(Phase::Review)));
512 assert!(loaded_tags.contains(&Tag::Label("high-priority".to_string())));
513 assert!(loaded_tags.contains(&Tag::Label("urgent".to_string())));
514 }
515
516 #[tokio::test]
517 async fn test_vision_validation() {
518 let vision = Vision::new(
519 "Test Vision".to_string(),
520 vec![Tag::Label("vision".to_string()), Tag::Phase(Phase::Draft)],
521 false,
522 )
523 .expect("Failed to create vision");
524
525 assert!(vision.validate().is_ok());
526 assert_eq!(vision.parent_id(), None);
527 assert_eq!(vision.blocked_by().len(), 0);
528
529 let temp_dir = tempdir().unwrap();
531 let file_path = temp_dir.path().join("test-vision.md");
532
533 vision.to_file(&file_path).await.unwrap();
535
536 let loaded_vision = Vision::from_file(&file_path).await.unwrap();
538
539 assert_eq!(loaded_vision.title(), vision.title());
541 assert_eq!(loaded_vision.phase().unwrap(), vision.phase().unwrap());
542 assert_eq!(loaded_vision.content().body, vision.content().body);
543 assert_eq!(loaded_vision.archived(), vision.archived());
544 assert_eq!(loaded_vision.tags().len(), vision.tags().len());
545 }
546
547 #[tokio::test]
548 async fn test_vision_phase_transitions() {
549 let vision = Vision::new(
550 "Test Vision".to_string(),
551 vec![Tag::Phase(Phase::Draft)],
552 false,
553 )
554 .expect("Failed to create vision");
555
556 assert!(vision.can_transition_to(Phase::Review));
557 assert!(!vision.can_transition_to(Phase::Published));
558 assert!(!vision.can_transition_to(Phase::Active));
559
560 let temp_dir = tempdir().unwrap();
562 let file_path = temp_dir.path().join("test-vision.md");
563
564 vision.to_file(&file_path).await.unwrap();
565 let loaded_vision = Vision::from_file(&file_path).await.unwrap();
566
567 assert_eq!(loaded_vision.title(), vision.title());
568 assert_eq!(loaded_vision.phase().unwrap(), vision.phase().unwrap());
569 assert!(loaded_vision.can_transition_to(Phase::Review));
570 assert!(!loaded_vision.can_transition_to(Phase::Published));
571 assert!(!loaded_vision.can_transition_to(Phase::Active));
572 }
573
574 #[tokio::test]
575 async fn test_vision_transition_phase_auto() {
576 let mut vision = Vision::new(
577 "Test Vision".to_string(),
578 vec![Tag::Phase(Phase::Draft)],
579 false,
580 )
581 .expect("Failed to create vision");
582
583 let new_phase = vision.transition_phase(None).unwrap();
585 assert_eq!(new_phase, Phase::Review);
586 assert_eq!(vision.phase().unwrap(), Phase::Review);
587
588 let temp_dir = tempdir().unwrap();
590 let file_path = temp_dir.path().join("test-vision.md");
591 vision.to_file(&file_path).await.unwrap();
592 let mut loaded_vision = Vision::from_file(&file_path).await.unwrap();
593 assert_eq!(loaded_vision.phase().unwrap(), Phase::Review);
594
595 let new_phase = loaded_vision.transition_phase(None).unwrap();
597 assert_eq!(new_phase, Phase::Published);
598 assert_eq!(loaded_vision.phase().unwrap(), Phase::Published);
599
600 loaded_vision.to_file(&file_path).await.unwrap();
602 let mut loaded_vision2 = Vision::from_file(&file_path).await.unwrap();
603 assert_eq!(loaded_vision2.phase().unwrap(), Phase::Published);
604
605 let new_phase = loaded_vision2.transition_phase(None).unwrap();
607 assert_eq!(new_phase, Phase::Published);
608 assert_eq!(loaded_vision2.phase().unwrap(), Phase::Published);
609
610 loaded_vision2.to_file(&file_path).await.unwrap();
612 let loaded_vision3 = Vision::from_file(&file_path).await.unwrap();
613 assert_eq!(loaded_vision3.phase().unwrap(), Phase::Published);
614 }
615
616 #[tokio::test]
617 async fn test_vision_transition_phase_explicit() {
618 let mut vision = Vision::new(
619 "Test Vision".to_string(),
620 vec![Tag::Phase(Phase::Draft)],
621 false,
622 )
623 .expect("Failed to create vision");
624
625 let new_phase = vision.transition_phase(Some(Phase::Review)).unwrap();
627 assert_eq!(new_phase, Phase::Review);
628 assert_eq!(vision.phase().unwrap(), Phase::Review);
629
630 let temp_dir = tempdir().unwrap();
632 let file_path = temp_dir.path().join("test-vision.md");
633 vision.to_file(&file_path).await.unwrap();
634 let mut loaded_vision = Vision::from_file(&file_path).await.unwrap();
635 assert_eq!(loaded_vision.phase().unwrap(), Phase::Review);
636
637 let new_phase = loaded_vision
639 .transition_phase(Some(Phase::Published))
640 .unwrap();
641 assert_eq!(new_phase, Phase::Published);
642 assert_eq!(loaded_vision.phase().unwrap(), Phase::Published);
643
644 loaded_vision.to_file(&file_path).await.unwrap();
646 let loaded_vision2 = Vision::from_file(&file_path).await.unwrap();
647 assert_eq!(loaded_vision2.phase().unwrap(), Phase::Published);
648 }
649
650 #[tokio::test]
651 async fn test_vision_transition_phase_invalid() {
652 let mut vision = Vision::new(
653 "Test Vision".to_string(),
654 vec![Tag::Phase(Phase::Draft)],
655 false,
656 )
657 .expect("Failed to create vision");
658
659 let result = vision.transition_phase(Some(Phase::Published));
661 assert!(result.is_err());
662 match result.unwrap_err() {
663 DocumentValidationError::InvalidPhaseTransition { from, to } => {
664 assert_eq!(from, Phase::Draft);
665 assert_eq!(to, Phase::Published);
666 }
667 _ => panic!("Expected InvalidPhaseTransition error"),
668 }
669
670 assert_eq!(vision.phase().unwrap(), Phase::Draft);
672
673 let temp_dir = tempdir().unwrap();
675 let file_path = temp_dir.path().join("test-vision.md");
676 vision.to_file(&file_path).await.unwrap();
677 let loaded_vision = Vision::from_file(&file_path).await.unwrap();
678 assert_eq!(loaded_vision.phase().unwrap(), Phase::Draft);
679 }
680
681 #[tokio::test]
682 async fn test_vision_update_section() {
683 let mut vision = Vision::new(
685 "Test Vision".to_string(),
686 vec![Tag::Phase(Phase::Draft)],
687 false,
688 )
689 .expect("Failed to create vision");
690
691 vision.core_mut().content = DocumentContent::new(
693 "## Purpose\n\nOriginal purpose\n\n## Current State\n\nOriginal state",
694 );
695
696 vision
698 .update_section("Updated purpose content", "Purpose", false)
699 .unwrap();
700 let content = vision.content().body.clone();
701 assert!(content.contains("## Purpose\n\nUpdated purpose content"));
702 assert!(!content.contains("Original purpose"));
703
704 vision
706 .update_section("Additional state info", "Current State", true)
707 .unwrap();
708 let content = vision.content().body.clone();
709 assert!(content.contains("Original state"));
710 assert!(content.contains("Additional state info"));
711
712 vision
714 .update_section("Future vision details", "Future State", false)
715 .unwrap();
716 let content = vision.content().body.clone();
717 assert!(content.contains("## Future State\n\nFuture vision details"));
718
719 let temp_dir = tempdir().unwrap();
721 let file_path = temp_dir.path().join("test-vision.md");
722 vision.to_file(&file_path).await.unwrap();
723 let loaded_vision = Vision::from_file(&file_path).await.unwrap();
724
725 let loaded_content = loaded_vision.content().body.clone();
726 assert!(loaded_content.contains("## Purpose\n\nUpdated purpose content"));
727 assert!(loaded_content.contains("Original state"));
728 assert!(loaded_content.contains("Additional state info"));
729 assert!(loaded_content.contains("## Future State\n\nFuture vision details"));
730 }
731}