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