1use crate::domain::documents::initiative::Complexity;
2use crate::domain::documents::strategy::RiskLevel;
3use crate::domain::documents::traits::Document;
4use crate::domain::documents::types::{DocumentId, DocumentType, Phase, Tag, ParentReference};
5use crate::domain::configuration::FlightLevelConfig;
6use crate::Result;
7use crate::{Adr, Initiative, MetisError, Strategy, Task, Vision};
8use std::fs;
9use std::path::{Path, PathBuf};
10
11pub struct DocumentCreationService {
13 workspace_dir: PathBuf,
14}
15
16#[derive(Debug, Clone)]
18pub struct DocumentCreationConfig {
19 pub title: String,
20 pub description: Option<String>,
21 pub parent_id: Option<DocumentId>,
22 pub tags: Vec<Tag>,
23 pub phase: Option<Phase>,
24 pub complexity: Option<Complexity>,
25 pub risk_level: Option<RiskLevel>,
26}
27
28#[derive(Debug)]
30pub struct CreationResult {
31 pub document_id: DocumentId,
32 pub document_type: DocumentType,
33 pub file_path: PathBuf,
34}
35
36impl DocumentCreationService {
37 pub fn new<P: AsRef<Path>>(workspace_dir: P) -> Self {
39 Self {
40 workspace_dir: workspace_dir.as_ref().to_path_buf(),
41 }
42 }
43
44 pub async fn create_vision(&self, config: DocumentCreationConfig) -> Result<CreationResult> {
46 let file_path = self.workspace_dir.join("vision.md");
48
49 if file_path.exists() {
51 return Err(MetisError::ValidationFailed {
52 message: "Vision document already exists".to_string(),
53 });
54 }
55
56 let mut tags = vec![
58 Tag::Label("vision".to_string()),
59 Tag::Phase(config.phase.unwrap_or(Phase::Draft)),
60 ];
61 tags.extend(config.tags);
62
63 let vision = Vision::new(
64 config.title.clone(),
65 tags,
66 false, )
68 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
69
70 if let Some(parent) = file_path.parent() {
72 fs::create_dir_all(parent).map_err(|e| MetisError::FileSystem(e.to_string()))?;
73 }
74
75 vision
77 .to_file(&file_path)
78 .await
79 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
80
81 Ok(CreationResult {
82 document_id: vision.id(),
83 document_type: DocumentType::Vision,
84 file_path,
85 })
86 }
87
88 pub async fn create_strategy(&self, config: DocumentCreationConfig) -> Result<CreationResult> {
90 let strategy_id = self.generate_id_from_title(&config.title);
92 let strategy_dir = self.workspace_dir.join("strategies").join(&strategy_id);
93 let file_path = strategy_dir.join("strategy.md");
94
95 if file_path.exists() {
97 return Err(MetisError::ValidationFailed {
98 message: format!("Strategy with ID '{}' already exists", strategy_id),
99 });
100 }
101
102 let mut tags = vec![
104 Tag::Label("strategy".to_string()),
105 Tag::Phase(config.phase.unwrap_or(Phase::Shaping)),
106 ];
107 tags.extend(config.tags);
108
109 let strategy = Strategy::new(
110 config.title.clone(),
111 config.parent_id,
112 Vec::new(), tags,
114 false, config.risk_level.unwrap_or(RiskLevel::Medium), Vec::new(), )
118 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
119
120 fs::create_dir_all(&strategy_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
122
123 strategy
125 .to_file(&file_path)
126 .await
127 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
128
129 Ok(CreationResult {
130 document_id: strategy.id(),
131 document_type: DocumentType::Strategy,
132 file_path,
133 })
134 }
135
136 pub async fn create_initiative(
138 &self,
139 config: DocumentCreationConfig,
140 strategy_id: &str,
141 ) -> Result<CreationResult> {
142 self.create_initiative_with_config(config, strategy_id, &FlightLevelConfig::full()).await
144 }
145
146 pub async fn create_initiative_with_config(
148 &self,
149 config: DocumentCreationConfig,
150 strategy_id: &str,
151 flight_config: &FlightLevelConfig,
152 ) -> Result<CreationResult> {
153 if !flight_config.initiatives_enabled {
155 let enabled_types: Vec<String> = flight_config.enabled_document_types().iter().map(|t| t.to_string()).collect();
156 return Err(MetisError::ValidationFailed {
157 message: format!(
158 "Initiative creation is disabled in current configuration ({} mode). Available document types: {}. To enable initiatives, use 'metis config set --preset full' or 'metis config set --initiatives true'",
159 flight_config.preset_name(),
160 enabled_types.join(", ")
161 ),
162 });
163 }
164
165 let initiative_id = self.generate_id_from_title(&config.title);
167
168 let (parent_ref, effective_strategy_id) = if flight_config.strategies_enabled {
170 if strategy_id == "NULL" {
172 return Err(MetisError::ValidationFailed {
173 message: format!(
174 "Cannot create initiative with NULL strategy when strategies are enabled in {} configuration. Provide a valid strategy_id",
175 flight_config.preset_name()
176 ),
177 });
178 }
179
180 let strategy_file = self
182 .workspace_dir
183 .join("strategies")
184 .join(strategy_id)
185 .join("strategy.md");
186 if !strategy_file.exists() {
187 return Err(MetisError::NotFound(format!(
188 "Parent strategy '{}' not found",
189 strategy_id
190 )));
191 }
192
193 (ParentReference::Some(DocumentId::from(strategy_id)), strategy_id)
194 } else {
195 (ParentReference::Null, "NULL")
197 };
198
199 let initiative_dir = self
201 .workspace_dir
202 .join("strategies")
203 .join(effective_strategy_id)
204 .join("initiatives")
205 .join(&initiative_id);
206
207 let file_path = initiative_dir.join("initiative.md");
208
209 if file_path.exists() {
211 return Err(MetisError::ValidationFailed {
212 message: format!("Initiative with ID '{}' already exists", initiative_id),
213 });
214 }
215
216 let mut tags = vec![
218 Tag::Label("initiative".to_string()),
219 Tag::Phase(config.phase.unwrap_or(Phase::Discovery)),
220 ];
221 tags.extend(config.tags);
222
223 let parent_id = config.parent_id.map(ParentReference::Some).unwrap_or(parent_ref);
225
226 let initiative = Initiative::new(
227 config.title.clone(),
228 parent_id.parent_id().cloned(), Some(DocumentId::from(effective_strategy_id)), Vec::new(), tags,
232 false, config.complexity.unwrap_or(Complexity::M), )
235 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
236
237 fs::create_dir_all(&initiative_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
239
240 initiative
242 .to_file(&file_path)
243 .await
244 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
245
246 Ok(CreationResult {
247 document_id: initiative.id(),
248 document_type: DocumentType::Initiative,
249 file_path,
250 })
251 }
252
253 pub async fn create_task(
255 &self,
256 config: DocumentCreationConfig,
257 strategy_id: &str,
258 initiative_id: &str,
259 ) -> Result<CreationResult> {
260 self.create_task_with_config(config, strategy_id, initiative_id, &FlightLevelConfig::full()).await
262 }
263
264 pub async fn create_task_with_config(
266 &self,
267 config: DocumentCreationConfig,
268 strategy_id: &str,
269 initiative_id: &str,
270 flight_config: &FlightLevelConfig,
271 ) -> Result<CreationResult> {
272 let task_id = self.generate_id_from_title(&config.title);
274
275 let (parent_ref, parent_title, effective_strategy_id, effective_initiative_id) = if flight_config.initiatives_enabled {
277 if initiative_id == "NULL" {
279 return Err(MetisError::ValidationFailed {
280 message: format!(
281 "Cannot create task with NULL initiative when initiatives are enabled in {} configuration. Provide a valid initiative_id or create the task as a backlog item",
282 flight_config.preset_name()
283 ),
284 });
285 }
286
287 let eff_strategy_id = if flight_config.strategies_enabled {
288 if strategy_id == "NULL" {
290 return Err(MetisError::ValidationFailed {
291 message: format!(
292 "Cannot create task with NULL strategy when strategies are enabled in {} configuration. Provide a valid strategy_id or create the task as a backlog item",
293 flight_config.preset_name()
294 ),
295 });
296 }
297 strategy_id
298 } else {
299 "NULL"
301 };
302
303 let initiative_file = self
305 .workspace_dir
306 .join("strategies")
307 .join(eff_strategy_id)
308 .join("initiatives")
309 .join(initiative_id)
310 .join("initiative.md");
311
312 if !initiative_file.exists() {
313 return Err(MetisError::NotFound(format!(
314 "Parent initiative '{}' not found",
315 initiative_id
316 )));
317 }
318
319 (ParentReference::Some(DocumentId::from(initiative_id)), Some(initiative_id.to_string()), eff_strategy_id, initiative_id)
320 } else {
321 (ParentReference::Null, None, "NULL", "NULL")
323 };
324
325 let task_dir = self
327 .workspace_dir
328 .join("strategies")
329 .join(effective_strategy_id)
330 .join("initiatives")
331 .join(effective_initiative_id)
332 .join("tasks");
333
334 let file_path = task_dir.join(format!("{}.md", task_id));
335
336 if file_path.exists() {
338 return Err(MetisError::ValidationFailed {
339 message: format!("Task with ID '{}' already exists", task_id),
340 });
341 }
342
343 let mut tags = vec![
345 Tag::Label("task".to_string()),
346 Tag::Phase(config.phase.unwrap_or(Phase::Todo)),
347 ];
348 tags.extend(config.tags);
349
350 let parent_id = config.parent_id.map(ParentReference::Some).unwrap_or(parent_ref);
352
353 let task = Task::new(
354 config.title.clone(),
355 parent_id.parent_id().cloned(), parent_title, if effective_strategy_id == "NULL" { None } else { Some(DocumentId::from(effective_strategy_id)) },
358 if effective_initiative_id == "NULL" { None } else { Some(DocumentId::from(effective_initiative_id)) },
359 Vec::new(), tags,
361 false, )
363 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
364
365 if !task_dir.exists() {
367 fs::create_dir_all(&task_dir)
368 .map_err(|e| MetisError::FileSystem(e.to_string()))?;
369 }
370
371 task.to_file(&file_path)
373 .await
374 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
375
376 Ok(CreationResult {
377 document_id: task.id(),
378 document_type: DocumentType::Task,
379 file_path,
380 })
381 }
382
383 pub async fn create_backlog_item(&self, config: DocumentCreationConfig) -> Result<CreationResult> {
385 let task_id = self.generate_id_from_title(&config.title);
387
388 let backlog_dir = self.determine_backlog_directory(&config.tags);
390 let file_path = backlog_dir.join(format!("{}.md", task_id));
391
392 if file_path.exists() {
394 return Err(MetisError::ValidationFailed {
395 message: format!("Backlog item with ID '{}' already exists", task_id),
396 });
397 }
398
399 let mut tags = vec![
401 Tag::Label("task".to_string()),
402 Tag::Phase(config.phase.unwrap_or(Phase::Backlog)),
403 ];
404 tags.extend(config.tags);
405
406 let task = Task::new(
407 config.title.clone(),
408 None, None, None, None, Vec::new(), tags,
414 false, )
416 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
417
418 if !backlog_dir.exists() {
420 fs::create_dir_all(&backlog_dir)
421 .map_err(|e| MetisError::FileSystem(e.to_string()))?;
422 }
423
424 task.to_file(&file_path)
426 .await
427 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
428
429 Ok(CreationResult {
430 document_id: task.id(),
431 document_type: DocumentType::Task,
432 file_path,
433 })
434 }
435
436 fn determine_backlog_directory(&self, tags: &[Tag]) -> PathBuf {
438 let base_backlog_dir = self.workspace_dir.join("backlog");
439
440 for tag in tags {
442 if let Tag::Label(label) = tag {
443 match label.as_str() {
444 "bug" => return base_backlog_dir.join("bugs"),
445 "feature" => return base_backlog_dir.join("features"),
446 "tech-debt" => return base_backlog_dir.join("tech-debt"),
447 _ => {}
448 }
449 }
450 }
451
452 base_backlog_dir
454 }
455
456 pub async fn create_adr(&self, config: DocumentCreationConfig) -> Result<CreationResult> {
458 let adr_number = self.get_next_adr_number()?;
460 let adr_slug = self.generate_id_from_title(&config.title);
461 let adr_filename = format!("{:03}-{}.md", adr_number, adr_slug);
462 let adrs_dir = self.workspace_dir.join("adrs");
463 let file_path = adrs_dir.join(&adr_filename);
464
465 if file_path.exists() {
467 return Err(MetisError::ValidationFailed {
468 message: format!("ADR with filename '{}' already exists", adr_filename),
469 });
470 }
471
472 let mut tags = vec![
474 Tag::Label("adr".to_string()),
475 Tag::Phase(config.phase.unwrap_or(Phase::Draft)),
476 ];
477 tags.extend(config.tags);
478
479 let adr = Adr::new(
480 adr_number,
481 config.title.clone(),
482 String::new(), None, config.parent_id,
485 tags,
486 false, )
488 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
489
490 fs::create_dir_all(&adrs_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
492
493 adr.to_file(&file_path)
495 .await
496 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
497
498 Ok(CreationResult {
499 document_id: adr.id(),
500 document_type: DocumentType::Adr,
501 file_path,
502 })
503 }
504
505 fn generate_id_from_title(&self, title: &str) -> String {
507 use crate::domain::documents::types::DocumentId;
508 DocumentId::title_to_slug(title)
509 }
510
511 fn get_next_adr_number(&self) -> Result<u32> {
513 let adrs_dir = self.workspace_dir.join("adrs");
514
515 if !adrs_dir.exists() {
516 return Ok(1);
517 }
518
519 let mut max_number = 0;
520 for entry in fs::read_dir(&adrs_dir).map_err(|e| MetisError::FileSystem(e.to_string()))? {
521 let entry = entry.map_err(|e| MetisError::FileSystem(e.to_string()))?;
522 let filename = entry.file_name().to_string_lossy().to_string();
523
524 if filename.ends_with(".md") {
525 if let Some(number_str) = filename.split('-').next() {
527 if let Ok(number) = number_str.parse::<u32>() {
528 max_number = max_number.max(number);
529 }
530 }
531 }
532 }
533
534 Ok(max_number + 1)
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541 use tempfile::tempdir;
542
543 #[tokio::test]
544 async fn test_create_vision_document() {
545 let temp_dir = tempdir().unwrap();
546 let workspace_dir = temp_dir.path().join(".metis");
547 fs::create_dir_all(&workspace_dir).unwrap();
548
549 let service = DocumentCreationService::new(&workspace_dir);
550 let config = DocumentCreationConfig {
551 title: "Test Vision".to_string(),
552 description: Some("A test vision document".to_string()),
553 parent_id: None,
554 tags: vec![],
555 phase: None,
556 complexity: None,
557 risk_level: None,
558 };
559
560 let result = service.create_vision(config).await.unwrap();
561
562 assert_eq!(result.document_type, DocumentType::Vision);
563 assert!(result.file_path.exists());
564
565 let vision = Vision::from_file(&result.file_path).await.unwrap();
567 assert_eq!(vision.title(), "Test Vision");
568 }
569
570 #[tokio::test]
571 async fn test_create_strategy_document() {
572 let temp_dir = tempdir().unwrap();
573 let workspace_dir = temp_dir.path().join(".metis");
574 fs::create_dir_all(&workspace_dir).unwrap();
575
576 let service = DocumentCreationService::new(&workspace_dir);
577 let config = DocumentCreationConfig {
578 title: "Test Strategy".to_string(),
579 description: Some("A test strategy document".to_string()),
580 parent_id: None,
581 tags: vec![],
582 phase: None,
583 complexity: None,
584 risk_level: None,
585 };
586
587 let result = service.create_strategy(config).await.unwrap();
588
589 assert_eq!(result.document_type, DocumentType::Strategy);
590 assert!(result.file_path.exists());
591
592 let strategy = Strategy::from_file(&result.file_path).await.unwrap();
594 assert_eq!(strategy.title(), "Test Strategy");
595 }
596
597 #[tokio::test]
598 async fn test_create_initiative_document() {
599 let temp_dir = tempdir().unwrap();
600 let workspace_dir = temp_dir.path().join(".metis");
601 fs::create_dir_all(&workspace_dir).unwrap();
602
603 let service = DocumentCreationService::new(&workspace_dir);
604
605 let strategy_config = DocumentCreationConfig {
607 title: "Parent Strategy".to_string(),
608 description: Some("A parent strategy".to_string()),
609 parent_id: None,
610 tags: vec![],
611 phase: None,
612 complexity: None,
613 risk_level: None,
614 };
615 let strategy_result = service.create_strategy(strategy_config).await.unwrap();
616 let strategy_id = strategy_result.document_id.to_string();
617
618 let initiative_config = DocumentCreationConfig {
620 title: "Test Initiative".to_string(),
621 description: Some("A test initiative document".to_string()),
622 parent_id: Some(strategy_result.document_id),
623 tags: vec![],
624 phase: None,
625 complexity: None,
626 risk_level: None,
627 };
628
629 let result = service
630 .create_initiative(initiative_config, &strategy_id)
631 .await
632 .unwrap();
633
634 assert_eq!(result.document_type, DocumentType::Initiative);
635 assert!(result.file_path.exists());
636
637 let initiative = Initiative::from_file(&result.file_path).await.unwrap();
639 assert_eq!(initiative.title(), "Test Initiative");
640 }
641
642 #[tokio::test]
643 async fn test_generate_id_from_title() {
644 let temp_dir = tempdir().unwrap();
645 let workspace_dir = temp_dir.path().join(".metis");
646
647 let service = DocumentCreationService::new(&workspace_dir);
648
649 assert_eq!(
650 service.generate_id_from_title("Test Strategy"),
651 "test-strategy"
652 );
653 assert_eq!(
654 service.generate_id_from_title("My Complex Title!"),
655 "my-complex-title"
656 );
657 assert_eq!(
658 service.generate_id_from_title("Multiple Spaces"),
659 "multiple-spaces"
660 );
661 }
662
663 #[tokio::test]
664 async fn test_get_next_adr_number() {
665 let temp_dir = tempdir().unwrap();
666 let workspace_dir = temp_dir.path().join(".metis");
667 let adrs_dir = workspace_dir.join("adrs");
668 fs::create_dir_all(&adrs_dir).unwrap();
669
670 let service = DocumentCreationService::new(&workspace_dir);
671
672 assert_eq!(service.get_next_adr_number().unwrap(), 1);
674
675 fs::write(adrs_dir.join("001-first-adr.md"), "content").unwrap();
677 fs::write(adrs_dir.join("002-second-adr.md"), "content").unwrap();
678
679 assert_eq!(service.get_next_adr_number().unwrap(), 3);
681 }
682
683 fn setup_test_service_temp() -> (DocumentCreationService, tempfile::TempDir) {
686 let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory");
687 let service = DocumentCreationService::new(temp_dir.path());
688 (service, temp_dir)
689 }
690
691 #[tokio::test]
692 async fn test_create_initiative_full_configuration() {
693 let (service, _temp) = setup_test_service_temp();
694 let flight_config = FlightLevelConfig::full();
695
696 let strategy_config = DocumentCreationConfig {
698 title: "Test Strategy".to_string(),
699 description: None,
700 parent_id: None,
701 tags: vec![],
702 phase: None,
703 complexity: None,
704 risk_level: None,
705 };
706
707 let strategy_result = service.create_strategy(strategy_config).await.unwrap();
708
709 let initiative_config = DocumentCreationConfig {
711 title: "Test Initiative".to_string(),
712 description: None,
713 parent_id: None,
714 tags: vec![],
715 phase: None,
716 complexity: None,
717 risk_level: None,
718 };
719
720 let result = service
721 .create_initiative_with_config(
722 initiative_config,
723 &strategy_result.document_id.to_string(),
724 &flight_config,
725 )
726 .await
727 .unwrap();
728
729 assert_eq!(result.document_type, DocumentType::Initiative);
730 assert!(result.file_path.exists());
731
732 assert!(result.file_path.to_string_lossy().contains("strategies"));
734 assert!(result.file_path.to_string_lossy().contains("initiatives"));
735 }
736
737 #[tokio::test]
738 async fn test_create_initiative_streamlined_configuration() {
739 let (service, _temp) = setup_test_service_temp();
740 let flight_config = FlightLevelConfig::streamlined();
741
742 let initiative_config = DocumentCreationConfig {
743 title: "Test Initiative".to_string(),
744 description: None,
745 parent_id: None,
746 tags: vec![],
747 phase: None,
748 complexity: None,
749 risk_level: None,
750 };
751
752 let result = service
754 .create_initiative_with_config(initiative_config, "NULL", &flight_config)
755 .await
756 .unwrap();
757
758 assert_eq!(result.document_type, DocumentType::Initiative);
759 assert!(result.file_path.exists());
760
761 assert!(result.file_path.to_string_lossy().contains("initiatives"));
763 assert!(result.file_path.to_string_lossy().contains("strategies/NULL"));
764 }
765
766 #[tokio::test]
767 async fn test_create_initiative_disabled_in_direct_configuration() {
768 let (service, _temp) = setup_test_service_temp();
769 let flight_config = FlightLevelConfig::direct();
770
771 let initiative_config = DocumentCreationConfig {
772 title: "Test Initiative".to_string(),
773 description: None,
774 parent_id: None,
775 tags: vec![],
776 phase: None,
777 complexity: None,
778 risk_level: None,
779 };
780
781 let result = service
783 .create_initiative_with_config(initiative_config, "NULL", &flight_config)
784 .await;
785
786 assert!(result.is_err());
787 assert!(result.unwrap_err().to_string().contains("Initiative creation is disabled"));
788 }
789
790 #[tokio::test]
791 async fn test_create_task_direct_configuration() {
792 let (service, _temp) = setup_test_service_temp();
793 let flight_config = FlightLevelConfig::direct();
794
795 let task_config = DocumentCreationConfig {
796 title: "Test Task".to_string(),
797 description: None,
798 parent_id: None,
799 tags: vec![],
800 phase: None,
801 complexity: None,
802 risk_level: None,
803 };
804
805 let result = service
807 .create_task_with_config(task_config, "NULL", "NULL", &flight_config)
808 .await
809 .unwrap();
810
811 assert_eq!(result.document_type, DocumentType::Task);
812 assert!(result.file_path.exists());
813
814 assert!(result.file_path.to_string_lossy().contains("tasks"));
816 assert!(result.file_path.to_string_lossy().contains("strategies/NULL/initiatives/NULL"));
817 }
818}