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, Clone, Copy, PartialEq, Eq)]
13pub enum RiskLevel {
14 Low,
15 Medium,
16 High,
17 Critical,
18}
19
20impl std::fmt::Display for RiskLevel {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 match self {
23 RiskLevel::Low => write!(f, "low"),
24 RiskLevel::Medium => write!(f, "medium"),
25 RiskLevel::High => write!(f, "high"),
26 RiskLevel::Critical => write!(f, "critical"),
27 }
28 }
29}
30
31impl std::str::FromStr for RiskLevel {
32 type Err = DocumentValidationError;
33
34 fn from_str(s: &str) -> Result<Self, Self::Err> {
35 match s.to_lowercase().as_str() {
36 "low" => Ok(RiskLevel::Low),
37 "medium" => Ok(RiskLevel::Medium),
38 "high" => Ok(RiskLevel::High),
39 "critical" => Ok(RiskLevel::Critical),
40 _ => Err(DocumentValidationError::InvalidContent(format!(
41 "Invalid risk level: {}",
42 s
43 ))),
44 }
45 }
46}
47
48#[derive(Debug)]
50pub struct Strategy {
51 core: super::traits::DocumentCore,
52 risk_level: RiskLevel,
53 stakeholders: Vec<String>,
54}
55
56impl Strategy {
57 pub fn new(
59 title: String,
60 parent_id: Option<DocumentId>, blocked_by: Vec<DocumentId>,
62 tags: Vec<Tag>,
63 archived: bool,
64 risk_level: RiskLevel,
65 stakeholders: Vec<String>,
66 ) -> Result<Self, DocumentValidationError> {
67 let metadata = DocumentMetadata::new();
69
70 let template_content = include_str!("content.md");
72 let mut tera = Tera::default();
73 tera.add_raw_template("strategy_content", template_content)
74 .map_err(|e| {
75 DocumentValidationError::InvalidContent(format!("Template error: {}", e))
76 })?;
77
78 let mut context = Context::new();
79 context.insert("title", &title);
80
81 let rendered_content = tera.render("strategy_content", &context).map_err(|e| {
82 DocumentValidationError::InvalidContent(format!("Template render error: {}", e))
83 })?;
84
85 let content = DocumentContent::new(&rendered_content);
86
87 Ok(Self {
88 core: super::traits::DocumentCore {
89 title,
90 metadata,
91 content,
92 parent_id,
93 blocked_by,
94 tags,
95 archived,
96 },
97 risk_level,
98 stakeholders,
99 })
100 }
101
102 #[allow(clippy::too_many_arguments)]
104 pub fn from_parts(
105 title: String,
106 metadata: DocumentMetadata,
107 content: DocumentContent,
108 parent_id: Option<DocumentId>,
109 blocked_by: Vec<DocumentId>,
110 tags: Vec<Tag>,
111 archived: bool,
112 risk_level: RiskLevel,
113 stakeholders: Vec<String>,
114 ) -> Self {
115 Self {
116 core: super::traits::DocumentCore {
117 title,
118 metadata,
119 content,
120 parent_id,
121 blocked_by,
122 tags,
123 archived,
124 },
125 risk_level,
126 stakeholders,
127 }
128 }
129
130 pub fn risk_level(&self) -> RiskLevel {
131 self.risk_level
132 }
133
134 pub fn stakeholders(&self) -> &[String] {
135 &self.stakeholders
136 }
137
138 fn next_phase_in_sequence(current: Phase) -> Option<Phase> {
140 use Phase::*;
141 match current {
142 Shaping => Some(Design),
143 Design => Some(Ready),
144 Ready => Some(Active),
145 Active => Some(Completed),
146 Completed => None, _ => None, }
149 }
150
151 fn update_phase_tag(&mut self, new_phase: Phase) {
153 self.core.tags.retain(|tag| !matches!(tag, Tag::Phase(_)));
155 self.core.tags.push(Tag::Phase(new_phase));
157 self.core.metadata.updated_at = Utc::now();
159 }
160
161 pub async fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, DocumentValidationError> {
163 let raw_content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
164 DocumentValidationError::InvalidContent(format!("Failed to read file: {}", e))
165 })?;
166
167 Self::from_content(&raw_content)
168 }
169
170 pub fn from_content(raw_content: &str) -> Result<Self, DocumentValidationError> {
172 let parsed = gray_matter::Matter::<gray_matter::engine::YAML>::new().parse(raw_content);
174
175 let frontmatter = parsed.data.ok_or_else(|| {
177 DocumentValidationError::MissingRequiredField("frontmatter".to_string())
178 })?;
179
180 let fm_map = match frontmatter {
182 gray_matter::Pod::Hash(map) => map,
183 _ => {
184 return Err(DocumentValidationError::InvalidContent(
185 "Frontmatter must be a hash/map".to_string(),
186 ))
187 }
188 };
189
190 let title = FrontmatterParser::extract_string(&fm_map, "title")?;
192 let archived = FrontmatterParser::extract_bool(&fm_map, "archived").unwrap_or(false);
193
194 let created_at = FrontmatterParser::extract_datetime(&fm_map, "created_at")?;
196 let updated_at = FrontmatterParser::extract_datetime(&fm_map, "updated_at")?;
197 let exit_criteria_met =
198 FrontmatterParser::extract_bool(&fm_map, "exit_criteria_met").unwrap_or(false);
199
200 let tags = FrontmatterParser::extract_tags(&fm_map)?;
202
203 let level = FrontmatterParser::extract_string(&fm_map, "level")?;
205 if level != "strategy" {
206 return Err(DocumentValidationError::InvalidContent(format!(
207 "Expected level 'strategy', found '{}'",
208 level
209 )));
210 }
211
212 let parent_id = FrontmatterParser::extract_string(&fm_map, "parent")
214 .ok()
215 .map(DocumentId::from);
216 let blocked_by = FrontmatterParser::extract_string_array(&fm_map, "blocked_by")
217 .unwrap_or_default()
218 .into_iter()
219 .map(DocumentId::from)
220 .collect();
221
222 let risk_level = FrontmatterParser::extract_string(&fm_map, "risk_level")
223 .and_then(|s| s.parse::<RiskLevel>())?;
224
225 let stakeholders = FrontmatterParser::extract_string_array(&fm_map, "stakeholders")?;
226
227 let metadata =
229 DocumentMetadata::from_frontmatter(created_at, updated_at, exit_criteria_met);
230 let content = DocumentContent::from_markdown(&parsed.content);
231
232 Ok(Self::from_parts(
233 title,
234 metadata,
235 content,
236 parent_id,
237 blocked_by,
238 tags,
239 archived,
240 risk_level,
241 stakeholders,
242 ))
243 }
244
245 pub async fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), DocumentValidationError> {
247 let content = self.to_content()?;
248 std::fs::write(path.as_ref(), content).map_err(|e| {
249 DocumentValidationError::InvalidContent(format!("Failed to write file: {}", e))
250 })
251 }
252
253 pub fn to_content(&self) -> Result<String, DocumentValidationError> {
255 let mut tera = Tera::default();
256
257 tera.add_raw_template("frontmatter", self.frontmatter_template())
259 .map_err(|e| {
260 DocumentValidationError::InvalidContent(format!("Template error: {}", e))
261 })?;
262
263 let mut context = Context::new();
265 context.insert("slug", &self.id().to_string());
266 context.insert("title", self.title());
267 context.insert("created_at", &self.metadata().created_at.to_rfc3339());
268 context.insert("updated_at", &self.metadata().updated_at.to_rfc3339());
269 context.insert("archived", &self.archived().to_string());
270 context.insert(
271 "exit_criteria_met",
272 &self.metadata().exit_criteria_met.to_string(),
273 );
274 context.insert(
275 "parent_id",
276 &self
277 .parent_id()
278 .map(|id| id.to_string())
279 .unwrap_or_default(),
280 );
281
282 let blocked_by_list: Vec<String> =
284 self.blocked_by().iter().map(|id| id.to_string()).collect();
285 context.insert("blocked_by", &blocked_by_list);
286 context.insert("risk_level", &self.risk_level.to_string());
287 context.insert("stakeholders", &self.stakeholders);
288
289 let tag_strings: Vec<String> = self.tags().iter().map(|tag| tag.to_str()).collect();
291 context.insert("tags", &tag_strings);
292
293 let frontmatter = tera.render("frontmatter", &context).map_err(|e| {
295 DocumentValidationError::InvalidContent(format!("Frontmatter render error: {}", e))
296 })?;
297
298 let content_body = &self.content().body;
300
301 let acceptance_criteria = if let Some(ac) = &self.content().acceptance_criteria {
303 format!("\n\n## Acceptance Criteria\n\n{}", ac)
304 } else {
305 String::new()
306 };
307
308 Ok(format!(
310 "---\n{}\n---\n\n{}{}",
311 frontmatter.trim_end(),
312 content_body,
313 acceptance_criteria
314 ))
315 }
316
317 }
319
320impl Document for Strategy {
321 fn document_type(&self) -> DocumentType {
324 DocumentType::Strategy
325 }
326
327 fn title(&self) -> &str {
328 &self.core.title
329 }
330
331 fn metadata(&self) -> &DocumentMetadata {
332 &self.core.metadata
333 }
334
335 fn content(&self) -> &DocumentContent {
336 &self.core.content
337 }
338
339 fn core(&self) -> &super::traits::DocumentCore {
340 &self.core
341 }
342
343 fn can_transition_to(&self, phase: Phase) -> bool {
344 if let Ok(current_phase) = self.phase() {
345 use Phase::*;
346 matches!(
347 (current_phase, phase),
348 (Shaping, Design) | (Design, Ready) | (Ready, Active) | (Active, Completed)
349 )
350 } else {
351 false }
353 }
354
355 fn parent_id(&self) -> Option<&DocumentId> {
356 self.core.parent_id.as_ref()
357 }
358
359 fn blocked_by(&self) -> &[DocumentId] {
360 &self.core.blocked_by
361 }
362
363 fn validate(&self) -> Result<(), DocumentValidationError> {
364 if self.title().trim().is_empty() {
366 return Err(DocumentValidationError::InvalidTitle(
367 "Strategy title cannot be empty".to_string(),
368 ));
369 }
370
371 if self.stakeholders.is_empty() {
372 return Err(DocumentValidationError::MissingRequiredField(
373 "Strategies must have at least one stakeholder".to_string(),
374 ));
375 }
376
377 Ok(())
378 }
379
380 fn exit_criteria_met(&self) -> bool {
381 false
385 }
386
387 fn template(&self) -> DocumentTemplate {
388 DocumentTemplate {
389 frontmatter: self.frontmatter_template(),
390 content: self.content_template(),
391 acceptance_criteria: self.acceptance_criteria_template(),
392 file_extension: "md",
393 }
394 }
395
396 fn frontmatter_template(&self) -> &'static str {
397 include_str!("frontmatter.yaml")
398 }
399
400 fn content_template(&self) -> &'static str {
401 include_str!("content.md")
402 }
403
404 fn acceptance_criteria_template(&self) -> &'static str {
405 include_str!("acceptance_criteria.md")
406 }
407
408 fn transition_phase(
409 &mut self,
410 target_phase: Option<Phase>,
411 ) -> Result<Phase, DocumentValidationError> {
412 let current_phase = self.phase()?;
413
414 let new_phase = match target_phase {
415 Some(phase) => {
416 if !self.can_transition_to(phase) {
418 return Err(DocumentValidationError::InvalidPhaseTransition {
419 from: current_phase,
420 to: phase,
421 });
422 }
423 phase
424 }
425 None => {
426 match Self::next_phase_in_sequence(current_phase) {
428 Some(next) => next,
429 None => return Ok(current_phase), }
431 }
432 };
433
434 self.update_phase_tag(new_phase);
435 Ok(new_phase)
436 }
437
438 fn core_mut(&mut self) -> &mut super::traits::DocumentCore {
439 &mut self.core
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446
447 use tempfile::tempdir;
448
449 #[tokio::test]
450 async fn test_strategy_new() {
451 let strategy = Strategy::new(
452 "Test Strategy".to_string(),
453 Some(DocumentId::from("parent-vision".to_string())),
454 Vec::new(),
455 vec![
456 Tag::Label("strategy".to_string()),
457 Tag::Phase(Phase::Shaping),
458 ],
459 false,
460 RiskLevel::Medium,
461 vec!["stakeholder1".to_string(), "stakeholder2".to_string()],
462 )
463 .expect("Failed to create strategy");
464
465 assert_eq!(strategy.title(), "Test Strategy");
466 assert_eq!(strategy.document_type(), DocumentType::Strategy);
467 assert!(!strategy.archived());
468 assert_eq!(strategy.risk_level(), RiskLevel::Medium);
469 assert_eq!(strategy.stakeholders().len(), 2);
470
471 let temp_dir = tempdir().unwrap();
473 let file_path = temp_dir.path().join("test-strategy.md");
474
475 strategy.to_file(&file_path).await.unwrap();
476 let loaded_strategy = Strategy::from_file(&file_path).await.unwrap();
477
478 assert_eq!(loaded_strategy.title(), strategy.title());
479 assert_eq!(loaded_strategy.phase().unwrap(), strategy.phase().unwrap());
480 assert_eq!(loaded_strategy.content().body, strategy.content().body);
481 assert_eq!(loaded_strategy.archived(), strategy.archived());
482 assert_eq!(loaded_strategy.risk_level(), strategy.risk_level());
483 assert_eq!(loaded_strategy.stakeholders(), strategy.stakeholders());
484 }
485
486 #[tokio::test]
487 async fn test_strategy_from_content() {
488 let content = r##"---
489id: test-strategy
490level: strategy
491title: "Test Strategy"
492created_at: 2025-01-01T00:00:00Z
493updated_at: 2025-01-01T00:00:00Z
494parent: parent-vision
495blocked_by: []
496archived: false
497
498tags:
499 - "#strategy"
500 - "#phase/shaping"
501
502exit_criteria_met: false
503risk_level: medium
504stakeholders: ["stakeholder1", "stakeholder2"]
505---
506
507# Test Strategy
508
509## Vision Alignment
510
511This strategy aligns with our vision.
512
513## Current State
514
515We are here.
516
517## Acceptance Criteria
518
519- [ ] Strategy is clearly defined
520- [ ] Stakeholders are identified
521"##;
522
523 let strategy = Strategy::from_content(content).unwrap();
524
525 assert_eq!(strategy.title(), "Test Strategy");
526 assert_eq!(strategy.document_type(), DocumentType::Strategy);
527 assert!(!strategy.archived());
528 assert_eq!(strategy.risk_level(), RiskLevel::Medium);
529 assert_eq!(strategy.stakeholders().len(), 2);
530 assert_eq!(strategy.phase().unwrap(), Phase::Shaping);
531
532 let temp_dir = tempdir().unwrap();
534 let file_path = temp_dir.path().join("test-strategy.md");
535
536 strategy.to_file(&file_path).await.unwrap();
537 let loaded_strategy = Strategy::from_file(&file_path).await.unwrap();
538
539 assert_eq!(loaded_strategy.title(), strategy.title());
540 assert_eq!(loaded_strategy.phase().unwrap(), strategy.phase().unwrap());
541 assert_eq!(loaded_strategy.content().body, strategy.content().body);
542 assert_eq!(loaded_strategy.risk_level(), strategy.risk_level());
543 assert_eq!(loaded_strategy.stakeholders(), strategy.stakeholders());
544 }
545
546 #[tokio::test]
547 async fn test_strategy_phase_transitions() {
548 let mut strategy = Strategy::new(
549 "Test Strategy".to_string(),
550 None,
551 Vec::new(),
552 vec![Tag::Phase(Phase::Shaping)],
553 false,
554 RiskLevel::Medium,
555 Vec::new(),
556 )
557 .expect("Failed to create strategy");
558
559 assert!(strategy.can_transition_to(Phase::Design));
560 assert!(!strategy.can_transition_to(Phase::Completed));
561
562 let new_phase = strategy.transition_phase(None).unwrap();
564 assert_eq!(new_phase, Phase::Design);
565 assert_eq!(strategy.phase().unwrap(), Phase::Design);
566
567 let temp_dir = tempdir().unwrap();
569 let file_path = temp_dir.path().join("test-strategy.md");
570 strategy.to_file(&file_path).await.unwrap();
571 let loaded_strategy = Strategy::from_file(&file_path).await.unwrap();
572 assert_eq!(loaded_strategy.phase().unwrap(), Phase::Design);
573 }
574
575 #[tokio::test]
576 async fn test_strategy_validation() {
577 let strategy = Strategy::new(
578 "Test Strategy".to_string(),
579 None,
580 Vec::new(),
581 vec![
582 Tag::Label("strategy".to_string()),
583 Tag::Phase(Phase::Shaping),
584 ],
585 false,
586 RiskLevel::High,
587 vec!["key-stakeholder".to_string()],
588 )
589 .expect("Failed to create strategy");
590
591 assert!(strategy.validate().is_ok());
592 assert_eq!(strategy.risk_level(), RiskLevel::High);
593
594 let temp_dir = tempdir().unwrap();
596 let file_path = temp_dir.path().join("test-strategy.md");
597
598 strategy.to_file(&file_path).await.unwrap();
599 let loaded_strategy = Strategy::from_file(&file_path).await.unwrap();
600
601 assert_eq!(loaded_strategy.title(), strategy.title());
602 assert_eq!(loaded_strategy.risk_level(), strategy.risk_level());
603 assert_eq!(loaded_strategy.stakeholders(), strategy.stakeholders());
604 assert!(loaded_strategy.validate().is_ok());
605 }
606}