metis_core/application/services/document/
creation.rs1use 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
10pub struct DocumentCreationService {
12 workspace_dir: PathBuf,
13}
14
15#[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#[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 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 pub async fn create_vision(&self, config: DocumentCreationConfig) -> Result<CreationResult> {
45 let file_path = self.workspace_dir.join("vision.md");
47
48 if file_path.exists() {
50 return Err(MetisError::ValidationFailed {
51 message: "Vision document already exists".to_string(),
52 });
53 }
54
55 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, )
67 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
68
69 if let Some(parent) = file_path.parent() {
71 fs::create_dir_all(parent).map_err(|e| MetisError::FileSystem(e.to_string()))?;
72 }
73
74 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 pub async fn create_strategy(&self, config: DocumentCreationConfig) -> Result<CreationResult> {
89 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 if file_path.exists() {
96 return Err(MetisError::ValidationFailed {
97 message: format!("Strategy with ID '{}' already exists", strategy_id),
98 });
99 }
100
101 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(), tags,
113 false, config.risk_level.unwrap_or(RiskLevel::Medium), Vec::new(), )
117 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
118
119 fs::create_dir_all(&strategy_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
121
122 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 pub async fn create_initiative(
137 &self,
138 config: DocumentCreationConfig,
139 strategy_id: &str,
140 ) -> Result<CreationResult> {
141 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 if file_path.exists() {
153 return Err(MetisError::ValidationFailed {
154 message: format!("Initiative with ID '{}' already exists", initiative_id),
155 });
156 }
157
158 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 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(), tags,
185 false, config.complexity.unwrap_or(Complexity::M), )
188 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
189
190 fs::create_dir_all(&initiative_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
192
193 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 pub async fn create_task(
208 &self,
209 config: DocumentCreationConfig,
210 strategy_id: &str,
211 initiative_id: &str,
212 ) -> Result<CreationResult> {
213 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 if file_path.exists() {
225 return Err(MetisError::ValidationFailed {
226 message: format!("Task with ID '{}' already exists", task_id),
227 });
228 }
229
230 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 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()), Vec::new(), tags,
254 false, )
256 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
257
258 if !initiative_dir.exists() {
260 fs::create_dir_all(&initiative_dir)
261 .map_err(|e| MetisError::FileSystem(e.to_string()))?;
262 }
263
264 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 pub async fn create_adr(&self, config: DocumentCreationConfig) -> Result<CreationResult> {
278 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 if file_path.exists() {
287 return Err(MetisError::ValidationFailed {
288 message: format!("ADR with filename '{}' already exists", adr_filename),
289 });
290 }
291
292 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(), None, config.parent_id,
305 tags,
306 false, )
308 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
309
310 fs::create_dir_all(&adrs_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
312
313 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 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 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 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 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 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 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 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 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 assert_eq!(service.get_next_adr_number().unwrap(), 1);
494
495 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 assert_eq!(service.get_next_adr_number().unwrap(), 3);
501 }
502}