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}
24
25#[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 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 pub async fn create_vision(&self, config: DocumentCreationConfig) -> Result<CreationResult> {
43 let file_path = self.workspace_dir.join("vision.md");
45
46 if file_path.exists() {
48 return Err(MetisError::ValidationFailed {
49 message: "Vision document already exists".to_string(),
50 });
51 }
52
53 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, )
65 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
66
67 if let Some(parent) = file_path.parent() {
69 fs::create_dir_all(parent).map_err(|e| MetisError::FileSystem(e.to_string()))?;
70 }
71
72 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 pub async fn create_strategy(&self, config: DocumentCreationConfig) -> Result<CreationResult> {
87 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 if file_path.exists() {
94 return Err(MetisError::ValidationFailed {
95 message: format!("Strategy with ID '{}' already exists", strategy_id),
96 });
97 }
98
99 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(), tags,
111 false, RiskLevel::Medium, Vec::new(), )
115 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
116
117 fs::create_dir_all(&strategy_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
119
120 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 pub async fn create_initiative(
135 &self,
136 config: DocumentCreationConfig,
137 strategy_id: &str,
138 ) -> Result<CreationResult> {
139 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 if file_path.exists() {
151 return Err(MetisError::ValidationFailed {
152 message: format!("Initiative with ID '{}' already exists", initiative_id),
153 });
154 }
155
156 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 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(), tags,
183 false, Complexity::M, )
186 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
187
188 fs::create_dir_all(&initiative_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
190
191 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 pub async fn create_task(
206 &self,
207 config: DocumentCreationConfig,
208 strategy_id: &str,
209 initiative_id: &str,
210 ) -> Result<CreationResult> {
211 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 if file_path.exists() {
223 return Err(MetisError::ValidationFailed {
224 message: format!("Task with ID '{}' already exists", task_id),
225 });
226 }
227
228 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 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()), Vec::new(), tags,
252 false, )
254 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
255
256 if !initiative_dir.exists() {
258 fs::create_dir_all(&initiative_dir)
259 .map_err(|e| MetisError::FileSystem(e.to_string()))?;
260 }
261
262 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 pub async fn create_adr(&self, config: DocumentCreationConfig) -> Result<CreationResult> {
276 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 if file_path.exists() {
285 return Err(MetisError::ValidationFailed {
286 message: format!("ADR with filename '{}' already exists", adr_filename),
287 });
288 }
289
290 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(), None, config.parent_id,
303 tags,
304 false, )
306 .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
307
308 fs::create_dir_all(&adrs_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
310
311 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 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 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 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 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 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 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 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 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 assert_eq!(service.get_next_adr_number().unwrap(), 1);
484
485 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 assert_eq!(service.get_next_adr_number().unwrap(), 3);
491 }
492}