metis_core/application/services/document/
creation.rs

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};
5use crate::Result;
6use crate::{Adr, Initiative, MetisError, Strategy, Task, Vision};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// Service for creating new documents with proper defaults and validation
11pub struct DocumentCreationService {
12    workspace_dir: PathBuf,
13}
14
15/// Configuration for creating a new document
16#[derive(Debug, Clone)]
17pub struct DocumentCreationConfig {
18    pub title: String,
19    pub description: Option<String>,
20    pub parent_id: Option<DocumentId>,
21    pub tags: Vec<Tag>,
22    pub phase: Option<Phase>,
23    pub complexity: Option<Complexity>,
24    pub risk_level: Option<RiskLevel>,
25}
26
27/// Result of document creation
28#[derive(Debug)]
29pub struct CreationResult {
30    pub document_id: DocumentId,
31    pub document_type: DocumentType,
32    pub file_path: PathBuf,
33}
34
35impl DocumentCreationService {
36    /// Create a new document creation service for a workspace
37    pub fn new<P: AsRef<Path>>(workspace_dir: P) -> Self {
38        Self {
39            workspace_dir: workspace_dir.as_ref().to_path_buf(),
40        }
41    }
42
43    /// Create a new vision document
44    pub async fn create_vision(&self, config: DocumentCreationConfig) -> Result<CreationResult> {
45        // Vision documents go directly in the workspace root
46        let file_path = self.workspace_dir.join("vision.md");
47
48        // Check if vision already exists
49        if file_path.exists() {
50            return Err(MetisError::ValidationFailed {
51                message: "Vision document already exists".to_string(),
52            });
53        }
54
55        // Create vision with defaults
56        let mut tags = vec![
57            Tag::Label("vision".to_string()),
58            Tag::Phase(config.phase.unwrap_or(Phase::Draft)),
59        ];
60        tags.extend(config.tags);
61
62        let vision = Vision::new(
63            config.title.clone(),
64            tags,
65            false, // not archived
66        )
67        .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
68
69        // Create parent directory if needed
70        if let Some(parent) = file_path.parent() {
71            fs::create_dir_all(parent).map_err(|e| MetisError::FileSystem(e.to_string()))?;
72        }
73
74        // Write to file
75        vision
76            .to_file(&file_path)
77            .await
78            .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
79
80        Ok(CreationResult {
81            document_id: vision.id(),
82            document_type: DocumentType::Vision,
83            file_path,
84        })
85    }
86
87    /// Create a new strategy document
88    pub async fn create_strategy(&self, config: DocumentCreationConfig) -> Result<CreationResult> {
89        // Generate strategy ID from title
90        let strategy_id = self.generate_id_from_title(&config.title);
91        let strategy_dir = self.workspace_dir.join("strategies").join(&strategy_id);
92        let file_path = strategy_dir.join("strategy.md");
93
94        // Check if strategy already exists
95        if file_path.exists() {
96            return Err(MetisError::ValidationFailed {
97                message: format!("Strategy with ID '{}' already exists", strategy_id),
98            });
99        }
100
101        // Create strategy with defaults
102        let mut tags = vec![
103            Tag::Label("strategy".to_string()),
104            Tag::Phase(config.phase.unwrap_or(Phase::Shaping)),
105        ];
106        tags.extend(config.tags);
107
108        let strategy = Strategy::new(
109            config.title.clone(),
110            config.parent_id,
111            Vec::new(), // blocked_by
112            tags,
113            false,                                     // not archived
114            config.risk_level.unwrap_or(RiskLevel::Medium), // use config risk_level or default to Medium
115            Vec::new(),                                // stakeholders - empty by default
116        )
117        .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
118
119        // Create parent directory
120        fs::create_dir_all(&strategy_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
121
122        // Write to file
123        strategy
124            .to_file(&file_path)
125            .await
126            .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
127
128        Ok(CreationResult {
129            document_id: strategy.id(),
130            document_type: DocumentType::Strategy,
131            file_path,
132        })
133    }
134
135    /// Create a new initiative document
136    pub async fn create_initiative(
137        &self,
138        config: DocumentCreationConfig,
139        strategy_id: &str,
140    ) -> Result<CreationResult> {
141        // Generate initiative ID from title
142        let initiative_id = self.generate_id_from_title(&config.title);
143        let initiative_dir = self
144            .workspace_dir
145            .join("strategies")
146            .join(strategy_id)
147            .join("initiatives")
148            .join(&initiative_id);
149        let file_path = initiative_dir.join("initiative.md");
150
151        // Check if initiative already exists
152        if file_path.exists() {
153            return Err(MetisError::ValidationFailed {
154                message: format!("Initiative with ID '{}' already exists", initiative_id),
155            });
156        }
157
158        // Validate parent strategy exists
159        let strategy_file = self
160            .workspace_dir
161            .join("strategies")
162            .join(strategy_id)
163            .join("strategy.md");
164        if !strategy_file.exists() {
165            return Err(MetisError::NotFound(format!(
166                "Parent strategy '{}' not found",
167                strategy_id
168            )));
169        }
170
171        // Create initiative with defaults
172        let mut tags = vec![
173            Tag::Label("initiative".to_string()),
174            Tag::Phase(config.phase.unwrap_or(Phase::Discovery)),
175        ];
176        tags.extend(config.tags);
177
178        let initiative = Initiative::new(
179            config.title.clone(),
180            config
181                .parent_id
182                .or_else(|| Some(DocumentId::from(strategy_id))),
183            Vec::new(), // blocked_by
184            tags,
185            false,                                      // not archived
186            config.complexity.unwrap_or(Complexity::M), // use config complexity or default to M
187        )
188        .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
189
190        // Create parent directory
191        fs::create_dir_all(&initiative_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
192
193        // Write to file
194        initiative
195            .to_file(&file_path)
196            .await
197            .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
198
199        Ok(CreationResult {
200            document_id: initiative.id(),
201            document_type: DocumentType::Initiative,
202            file_path,
203        })
204    }
205
206    /// Create a new task document
207    pub async fn create_task(
208        &self,
209        config: DocumentCreationConfig,
210        strategy_id: &str,
211        initiative_id: &str,
212    ) -> Result<CreationResult> {
213        // Generate task ID from title
214        let task_id = self.generate_id_from_title(&config.title);
215        let initiative_dir = self
216            .workspace_dir
217            .join("strategies")
218            .join(strategy_id)
219            .join("initiatives")
220            .join(initiative_id);
221        let file_path = initiative_dir.join(format!("{}.md", task_id));
222
223        // Check if task already exists
224        if file_path.exists() {
225            return Err(MetisError::ValidationFailed {
226                message: format!("Task with ID '{}' already exists", task_id),
227            });
228        }
229
230        // Validate parent initiative exists
231        let initiative_file = initiative_dir.join("initiative.md");
232        if !initiative_file.exists() {
233            return Err(MetisError::NotFound(format!(
234                "Parent initiative '{}' not found",
235                initiative_id
236            )));
237        }
238
239        // Create task with defaults
240        let mut tags = vec![
241            Tag::Label("task".to_string()),
242            Tag::Phase(config.phase.unwrap_or(Phase::Todo)),
243        ];
244        tags.extend(config.tags);
245
246        let task = Task::new(
247            config.title.clone(),
248            config
249                .parent_id
250                .or_else(|| Some(DocumentId::from(initiative_id))),
251            Some(initiative_id.to_string()), // parent title for template
252            Vec::new(),                      // blocked_by
253            tags,
254            false, // not archived
255        )
256        .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
257
258        // Create parent directory if needed
259        if !initiative_dir.exists() {
260            fs::create_dir_all(&initiative_dir)
261                .map_err(|e| MetisError::FileSystem(e.to_string()))?;
262        }
263
264        // Write to file
265        task.to_file(&file_path)
266            .await
267            .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
268
269        Ok(CreationResult {
270            document_id: task.id(),
271            document_type: DocumentType::Task,
272            file_path,
273        })
274    }
275
276    /// Create a new ADR document
277    pub async fn create_adr(&self, config: DocumentCreationConfig) -> Result<CreationResult> {
278        // Find the next ADR number
279        let adr_number = self.get_next_adr_number()?;
280        let adr_slug = self.generate_id_from_title(&config.title);
281        let adr_filename = format!("{:03}-{}.md", adr_number, adr_slug);
282        let adrs_dir = self.workspace_dir.join("adrs");
283        let file_path = adrs_dir.join(&adr_filename);
284
285        // Check if ADR already exists
286        if file_path.exists() {
287            return Err(MetisError::ValidationFailed {
288                message: format!("ADR with filename '{}' already exists", adr_filename),
289            });
290        }
291
292        // Create ADR with defaults
293        let mut tags = vec![
294            Tag::Label("adr".to_string()),
295            Tag::Phase(config.phase.unwrap_or(Phase::Draft)),
296        ];
297        tags.extend(config.tags);
298
299        let adr = Adr::new(
300            adr_number,
301            config.title.clone(),
302            String::new(), // decision_maker - will be set when transitioning to decided
303            None,          // decision_date - will be set when transitioning to decided
304            config.parent_id,
305            tags,
306            false, // not archived
307        )
308        .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
309
310        // Create parent directory
311        fs::create_dir_all(&adrs_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
312
313        // Write to file
314        adr.to_file(&file_path)
315            .await
316            .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
317
318        Ok(CreationResult {
319            document_id: adr.id(),
320            document_type: DocumentType::Adr,
321            file_path,
322        })
323    }
324
325    /// Generate a slugified ID from a title (same as DocumentId::title_to_slug)
326    fn generate_id_from_title(&self, title: &str) -> String {
327        use crate::domain::documents::types::DocumentId;
328        DocumentId::title_to_slug(title)
329    }
330
331    /// Get the next ADR number by examining existing ADRs
332    fn get_next_adr_number(&self) -> Result<u32> {
333        let adrs_dir = self.workspace_dir.join("adrs");
334
335        if !adrs_dir.exists() {
336            return Ok(1);
337        }
338
339        let mut max_number = 0;
340        for entry in fs::read_dir(&adrs_dir).map_err(|e| MetisError::FileSystem(e.to_string()))? {
341            let entry = entry.map_err(|e| MetisError::FileSystem(e.to_string()))?;
342            let filename = entry.file_name().to_string_lossy().to_string();
343
344            if filename.ends_with(".md") {
345                // Parse number from filename like "001-title.md"
346                if let Some(number_str) = filename.split('-').next() {
347                    if let Ok(number) = number_str.parse::<u32>() {
348                        max_number = max_number.max(number);
349                    }
350                }
351            }
352        }
353
354        Ok(max_number + 1)
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use tempfile::tempdir;
362
363    #[tokio::test]
364    async fn test_create_vision_document() {
365        let temp_dir = tempdir().unwrap();
366        let workspace_dir = temp_dir.path().join(".metis");
367        fs::create_dir_all(&workspace_dir).unwrap();
368
369        let service = DocumentCreationService::new(&workspace_dir);
370        let config = DocumentCreationConfig {
371            title: "Test Vision".to_string(),
372            description: Some("A test vision document".to_string()),
373            parent_id: None,
374            tags: vec![],
375            phase: None,
376            complexity: None,
377            risk_level: None,
378        };
379
380        let result = service.create_vision(config).await.unwrap();
381
382        assert_eq!(result.document_type, DocumentType::Vision);
383        assert!(result.file_path.exists());
384
385        // Verify we can read it back
386        let vision = Vision::from_file(&result.file_path).await.unwrap();
387        assert_eq!(vision.title(), "Test Vision");
388    }
389
390    #[tokio::test]
391    async fn test_create_strategy_document() {
392        let temp_dir = tempdir().unwrap();
393        let workspace_dir = temp_dir.path().join(".metis");
394        fs::create_dir_all(&workspace_dir).unwrap();
395
396        let service = DocumentCreationService::new(&workspace_dir);
397        let config = DocumentCreationConfig {
398            title: "Test Strategy".to_string(),
399            description: Some("A test strategy document".to_string()),
400            parent_id: None,
401            tags: vec![],
402            phase: None,
403            complexity: None,
404            risk_level: None,
405        };
406
407        let result = service.create_strategy(config).await.unwrap();
408
409        assert_eq!(result.document_type, DocumentType::Strategy);
410        assert!(result.file_path.exists());
411
412        // Verify we can read it back
413        let strategy = Strategy::from_file(&result.file_path).await.unwrap();
414        assert_eq!(strategy.title(), "Test Strategy");
415    }
416
417    #[tokio::test]
418    async fn test_create_initiative_document() {
419        let temp_dir = tempdir().unwrap();
420        let workspace_dir = temp_dir.path().join(".metis");
421        fs::create_dir_all(&workspace_dir).unwrap();
422
423        let service = DocumentCreationService::new(&workspace_dir);
424
425        // First create a parent strategy
426        let strategy_config = DocumentCreationConfig {
427            title: "Parent Strategy".to_string(),
428            description: Some("A parent strategy".to_string()),
429            parent_id: None,
430            tags: vec![],
431            phase: None,
432            complexity: None,
433            risk_level: None,
434        };
435        let strategy_result = service.create_strategy(strategy_config).await.unwrap();
436        let strategy_id = strategy_result.document_id.to_string();
437
438        // Now create an initiative
439        let initiative_config = DocumentCreationConfig {
440            title: "Test Initiative".to_string(),
441            description: Some("A test initiative document".to_string()),
442            parent_id: Some(strategy_result.document_id),
443            tags: vec![],
444            phase: None,
445            complexity: None,
446            risk_level: None,
447        };
448
449        let result = service
450            .create_initiative(initiative_config, &strategy_id)
451            .await
452            .unwrap();
453
454        assert_eq!(result.document_type, DocumentType::Initiative);
455        assert!(result.file_path.exists());
456
457        // Verify we can read it back
458        let initiative = Initiative::from_file(&result.file_path).await.unwrap();
459        assert_eq!(initiative.title(), "Test Initiative");
460    }
461
462    #[tokio::test]
463    async fn test_generate_id_from_title() {
464        let temp_dir = tempdir().unwrap();
465        let workspace_dir = temp_dir.path().join(".metis");
466
467        let service = DocumentCreationService::new(&workspace_dir);
468
469        assert_eq!(
470            service.generate_id_from_title("Test Strategy"),
471            "test-strategy"
472        );
473        assert_eq!(
474            service.generate_id_from_title("My Complex Title!"),
475            "my-complex-title"
476        );
477        assert_eq!(
478            service.generate_id_from_title("Multiple   Spaces"),
479            "multiple-spaces"
480        );
481    }
482
483    #[tokio::test]
484    async fn test_get_next_adr_number() {
485        let temp_dir = tempdir().unwrap();
486        let workspace_dir = temp_dir.path().join(".metis");
487        let adrs_dir = workspace_dir.join("adrs");
488        fs::create_dir_all(&adrs_dir).unwrap();
489
490        let service = DocumentCreationService::new(&workspace_dir);
491
492        // Should start at 1 when no ADRs exist
493        assert_eq!(service.get_next_adr_number().unwrap(), 1);
494
495        // Create some ADR files
496        fs::write(adrs_dir.join("001-first-adr.md"), "content").unwrap();
497        fs::write(adrs_dir.join("002-second-adr.md"), "content").unwrap();
498
499        // Should return 3 as next number
500        assert_eq!(service.get_next_adr_number().unwrap(), 3);
501    }
502}