metis_core/application/services/document/
creation.rs

1use crate::dal::database::configuration_repository::ConfigurationRepository;
2use crate::domain::configuration::FlightLevelConfig;
3use crate::domain::documents::initiative::Complexity;
4use crate::domain::documents::strategy::RiskLevel;
5use crate::domain::documents::traits::Document;
6use crate::domain::documents::types::{DocumentId, DocumentType, ParentReference, Phase, Tag};
7use crate::Result;
8use crate::{Adr, Database, Initiative, MetisError, Strategy, Task, Vision};
9use diesel::{sqlite::SqliteConnection, Connection};
10use std::fs;
11use std::path::{Path, PathBuf};
12
13/// Service for creating new documents with proper defaults and validation
14pub struct DocumentCreationService {
15    workspace_dir: PathBuf,
16    db_path: PathBuf,
17}
18
19/// Configuration for creating a new document
20#[derive(Debug, Clone)]
21pub struct DocumentCreationConfig {
22    pub title: String,
23    pub description: Option<String>,
24    pub parent_id: Option<DocumentId>,
25    pub tags: Vec<Tag>,
26    pub phase: Option<Phase>,
27    pub complexity: Option<Complexity>,
28    pub risk_level: Option<RiskLevel>,
29}
30
31/// Result of document creation
32#[derive(Debug)]
33pub struct CreationResult {
34    pub document_id: DocumentId,
35    pub document_type: DocumentType,
36    pub file_path: PathBuf,
37    pub short_code: String,
38}
39
40impl DocumentCreationService {
41    /// Create a new document creation service for a workspace
42    pub fn new<P: AsRef<Path>>(workspace_dir: P) -> Self {
43        let workspace_path = workspace_dir.as_ref().to_path_buf();
44        let db_path = workspace_path.join("metis.db");
45        Self {
46            workspace_dir: workspace_path,
47            db_path,
48        }
49    }
50
51    /// Generate a short code for a document type
52    fn generate_short_code(&self, doc_type: &str) -> Result<String> {
53        let mut config_repo = ConfigurationRepository::new(
54            SqliteConnection::establish(&self.db_path.to_string_lossy()).map_err(|e| {
55                MetisError::ConfigurationError(
56                    crate::domain::configuration::ConfigurationError::InvalidValue(e.to_string()),
57                )
58            })?,
59        );
60
61        config_repo.generate_short_code(doc_type)
62    }
63
64    /// Create a new vision document
65    pub async fn create_vision(&self, config: DocumentCreationConfig) -> Result<CreationResult> {
66        // Vision documents go directly in the workspace root
67        let file_path = self.workspace_dir.join("vision.md");
68
69        // Check if vision already exists
70        if file_path.exists() {
71            return Err(MetisError::ValidationFailed {
72                message: "Vision document already exists".to_string(),
73            });
74        }
75
76        // Generate short code for vision
77        let short_code = self.generate_short_code("vision")?;
78
79        // Create vision with defaults
80        let mut tags = vec![
81            Tag::Label("vision".to_string()),
82            Tag::Phase(config.phase.unwrap_or(Phase::Draft)),
83        ];
84        tags.extend(config.tags);
85
86        let vision = Vision::new(
87            config.title.clone(),
88            tags,
89            false, // not archived
90            short_code.clone(),
91        )
92        .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
93
94        // Create parent directory if needed
95        if let Some(parent) = file_path.parent() {
96            fs::create_dir_all(parent).map_err(|e| MetisError::FileSystem(e.to_string()))?;
97        }
98
99        // Write to file
100        vision
101            .to_file(&file_path)
102            .await
103            .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
104
105        Ok(CreationResult {
106            document_id: vision.id(),
107            document_type: DocumentType::Vision,
108            file_path,
109            short_code,
110        })
111    }
112
113    /// Create a new strategy document
114    pub async fn create_strategy(&self, config: DocumentCreationConfig) -> Result<CreationResult> {
115        // Generate short code for strategy (used for both ID and file path)
116        let short_code = self.generate_short_code("strategy")?;
117        let strategy_dir = self.workspace_dir.join("strategies").join(&short_code);
118        let file_path = strategy_dir.join("strategy.md");
119
120        // Check if strategy already exists
121        if file_path.exists() {
122            return Err(MetisError::ValidationFailed {
123                message: format!("Strategy with short code '{}' already exists", short_code),
124            });
125        }
126
127        // Create strategy with defaults
128        let mut tags = vec![
129            Tag::Label("strategy".to_string()),
130            Tag::Phase(config.phase.unwrap_or(Phase::Shaping)),
131        ];
132        tags.extend(config.tags);
133
134        let strategy = Strategy::new(
135            config.title.clone(),
136            config.parent_id,
137            Vec::new(), // blocked_by
138            tags,
139            false,                                          // not archived
140            config.risk_level.unwrap_or(RiskLevel::Medium), // use config risk_level or default to Medium
141            Vec::new(),                                     // stakeholders - empty by default
142            short_code.clone(),
143        )
144        .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
145
146        // Create parent directory
147        fs::create_dir_all(&strategy_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
148
149        // Write to file
150        strategy
151            .to_file(&file_path)
152            .await
153            .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
154
155        Ok(CreationResult {
156            document_id: strategy.id(),
157            document_type: DocumentType::Strategy,
158            file_path,
159            short_code,
160        })
161    }
162
163    /// Create a new initiative document (legacy method)
164    pub async fn create_initiative(
165        &self,
166        config: DocumentCreationConfig,
167        strategy_id: &str,
168    ) -> Result<CreationResult> {
169        // Use full configuration for backward compatibility
170        self.create_initiative_with_config(config, strategy_id, &FlightLevelConfig::full())
171            .await
172    }
173
174    /// Create a new initiative document with flight level configuration
175    pub async fn create_initiative_with_config(
176        &self,
177        config: DocumentCreationConfig,
178        strategy_id: &str,
179        flight_config: &FlightLevelConfig,
180    ) -> Result<CreationResult> {
181        // Validate that initiatives are enabled in this configuration
182        if !flight_config.initiatives_enabled {
183            let enabled_types: Vec<String> = flight_config
184                .enabled_document_types()
185                .iter()
186                .map(|t| t.to_string())
187                .collect();
188            return Err(MetisError::ValidationFailed {
189                message: format!(
190                    "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'",
191                    flight_config.preset_name(),
192                    enabled_types.join(", ")
193                ),
194            });
195        }
196
197        // Generate short code for initiative (used for both ID and file path)
198        let short_code = self.generate_short_code("initiative")?;
199
200        // Determine the strategy short code first (outside conditionals to avoid lifetime issues)
201        let strategy_short_code = if flight_config.strategies_enabled && strategy_id != "NULL" {
202            // Validate parent strategy exists by looking up its short code in database
203            let db_path = self.workspace_dir.join("metis.db");
204            let db = Database::new(db_path.to_str().unwrap())
205                .map_err(|e| MetisError::FileSystem(format!("Database error: {}", e)))?;
206            let mut repo = db
207                .repository()
208                .map_err(|e| MetisError::FileSystem(format!("Repository error: {}", e)))?;
209
210            // Find the strategy by short code
211            let strategy = repo
212                .find_by_short_code(strategy_id)
213                .map_err(|e| MetisError::FileSystem(format!("Database lookup error: {}", e)))?
214                .ok_or_else(|| {
215                    MetisError::NotFound(format!("Parent strategy '{}' not found", strategy_id))
216                })?;
217
218            // Use the short code to build the file path
219            let strategy_file = self
220                .workspace_dir
221                .join("strategies")
222                .join(&strategy.short_code)
223                .join("strategy.md");
224            if !strategy_file.exists() {
225                return Err(MetisError::NotFound(format!(
226                    "Parent strategy '{}' not found at expected path",
227                    strategy_id
228                )));
229            }
230
231            strategy.short_code
232        } else {
233            "NULL".to_string()
234        };
235
236        // Determine directory structure using consistent NULL-based paths
237        let (parent_ref, effective_strategy_id) = if flight_config.strategies_enabled {
238            // Full configuration: use actual strategy_id
239            if strategy_id == "NULL" {
240                return Err(MetisError::ValidationFailed {
241                    message: format!(
242                        "Cannot create initiative with NULL strategy when strategies are enabled in {} configuration. Provide a valid strategy_id",
243                        flight_config.preset_name()
244                    ),
245                });
246            }
247
248            (
249                ParentReference::Some(DocumentId::from(strategy_id)),
250                strategy_short_code.as_str(),
251            )
252        } else {
253            // Streamlined configuration: use NULL as strategy placeholder
254            (ParentReference::Null, "NULL")
255        };
256
257        // Consistent directory structure: strategies/{strategy_short_code}/initiatives/{initiative_short_code}
258        let initiative_dir = self
259            .workspace_dir
260            .join("strategies")
261            .join(effective_strategy_id)
262            .join("initiatives")
263            .join(&short_code);
264
265        let file_path = initiative_dir.join("initiative.md");
266
267        // Check if initiative already exists
268        if file_path.exists() {
269            return Err(MetisError::ValidationFailed {
270                message: format!("Initiative with short code '{}' already exists", short_code),
271            });
272        }
273
274        // Create initiative with defaults
275        let mut tags = vec![
276            Tag::Label("initiative".to_string()),
277            Tag::Phase(config.phase.unwrap_or(Phase::Discovery)),
278        ];
279        tags.extend(config.tags);
280
281        // Use the parent reference from configuration, or explicit parent_id from config
282        let parent_id = config
283            .parent_id
284            .map(ParentReference::Some)
285            .unwrap_or(parent_ref);
286
287        let initiative = Initiative::new(
288            config.title.clone(),
289            parent_id.parent_id().cloned(), // Extract actual parent ID for document creation
290            Some(DocumentId::from(effective_strategy_id)), // strategy_id from configuration
291            Vec::new(),                     // blocked_by
292            tags,
293            false,                                      // not archived
294            config.complexity.unwrap_or(Complexity::M), // use config complexity or default to M
295            short_code.clone(),
296        )
297        .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
298
299        // Create parent directory
300        fs::create_dir_all(&initiative_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
301
302        // Write to file
303        initiative
304            .to_file(&file_path)
305            .await
306            .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
307
308        Ok(CreationResult {
309            document_id: initiative.id(),
310            document_type: DocumentType::Initiative,
311            file_path,
312            short_code,
313        })
314    }
315
316    /// Create a new task document (legacy method)
317    pub async fn create_task(
318        &self,
319        config: DocumentCreationConfig,
320        strategy_id: &str,
321        initiative_id: &str,
322    ) -> Result<CreationResult> {
323        // Use full configuration for backward compatibility
324        self.create_task_with_config(
325            config,
326            strategy_id,
327            initiative_id,
328            &FlightLevelConfig::full(),
329        )
330        .await
331    }
332
333    /// Create a new task document with flight level configuration
334    pub async fn create_task_with_config(
335        &self,
336        config: DocumentCreationConfig,
337        strategy_id: &str,
338        initiative_id: &str,
339        flight_config: &FlightLevelConfig,
340    ) -> Result<CreationResult> {
341        // Generate short code for task (used for both ID and file path)
342        let short_code = self.generate_short_code("task")?;
343
344        // Resolve short codes first (outside conditionals to avoid lifetime issues)
345        let (strategy_short_code, initiative_short_code) = if flight_config.initiatives_enabled
346            && initiative_id != "NULL"
347        {
348            // Validate parent initiative exists by looking up its short codes in database
349            let db_path = self.workspace_dir.join("metis.db");
350            let db = Database::new(db_path.to_str().unwrap())
351                .map_err(|e| MetisError::FileSystem(format!("Database error: {}", e)))?;
352            let mut repo = db
353                .repository()
354                .map_err(|e| MetisError::FileSystem(format!("Repository error: {}", e)))?;
355
356            // Find the initiative by short code
357            let initiative = repo
358                .find_by_short_code(initiative_id)
359                .map_err(|e| MetisError::FileSystem(format!("Database lookup error: {}", e)))?
360                .ok_or_else(|| {
361                    MetisError::NotFound(format!("Parent initiative '{}' not found", initiative_id))
362                })?;
363
364            // If strategies are enabled, also get the strategy short code
365            let strategy_short_code = if flight_config.strategies_enabled && strategy_id != "NULL" {
366                let strategy = repo
367                    .find_by_short_code(strategy_id)
368                    .map_err(|e| MetisError::FileSystem(format!("Database lookup error: {}", e)))?
369                    .ok_or_else(|| {
370                        MetisError::NotFound(format!("Parent strategy '{}' not found", strategy_id))
371                    })?;
372                strategy.short_code
373            } else {
374                "NULL".to_string()
375            };
376
377            // Use the short codes to build the file path
378            let initiative_file = self
379                .workspace_dir
380                .join("strategies")
381                .join(&strategy_short_code)
382                .join("initiatives")
383                .join(&initiative.short_code)
384                .join("initiative.md");
385
386            if !initiative_file.exists() {
387                return Err(MetisError::NotFound(format!(
388                    "Parent initiative '{}' not found at expected path",
389                    initiative_id
390                )));
391            }
392
393            (strategy_short_code, initiative.short_code)
394        } else {
395            ("NULL".to_string(), "NULL".to_string())
396        };
397
398        // Determine directory structure using consistent NULL-based paths
399        let (parent_ref, parent_title, effective_strategy_id, effective_initiative_id) =
400            if flight_config.initiatives_enabled {
401                // Initiatives are enabled, tasks go under initiatives
402                if initiative_id == "NULL" {
403                    return Err(MetisError::ValidationFailed {
404                    message: format!(
405                        "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",
406                        flight_config.preset_name()
407                    ),
408                });
409                }
410
411                // Validation was done earlier, use the pre-computed short codes
412                if flight_config.strategies_enabled && strategy_id == "NULL" {
413                    return Err(MetisError::ValidationFailed {
414                    message: format!(
415                        "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",
416                        flight_config.preset_name()
417                    ),
418                });
419                }
420
421                (
422                    ParentReference::Some(DocumentId::from(initiative_id)),
423                    Some(initiative_id.to_string()),
424                    strategy_short_code.as_str(),
425                    initiative_short_code.as_str(),
426                )
427            } else {
428                // Direct configuration: use NULL placeholders for both strategy and initiative
429                (ParentReference::Null, None, "NULL", "NULL")
430            };
431
432        // Consistent directory structure: strategies/{strategy_short_code}/initiatives/{initiative_short_code}/tasks/{task_short_code}
433        let task_dir = self
434            .workspace_dir
435            .join("strategies")
436            .join(effective_strategy_id)
437            .join("initiatives")
438            .join(effective_initiative_id)
439            .join("tasks");
440
441        let file_path = task_dir.join(format!("{}.md", short_code));
442
443        // Check if task already exists
444        if file_path.exists() {
445            return Err(MetisError::ValidationFailed {
446                message: format!("Task with short code '{}' already exists", short_code),
447            });
448        }
449
450        // Create task with defaults
451        let mut tags = vec![
452            Tag::Label("task".to_string()),
453            Tag::Phase(config.phase.unwrap_or(Phase::Todo)),
454        ];
455        tags.extend(config.tags);
456
457        // Use the parent reference from configuration, or explicit parent_id from config
458        let parent_id = config
459            .parent_id
460            .map(ParentReference::Some)
461            .unwrap_or(parent_ref);
462
463        let task = Task::new(
464            config.title.clone(),
465            parent_id.parent_id().cloned(), // Extract actual parent ID for document creation
466            parent_title,                   // parent title for template
467            if effective_strategy_id == "NULL" {
468                None
469            } else {
470                Some(DocumentId::from(effective_strategy_id))
471            },
472            if effective_initiative_id == "NULL" {
473                None
474            } else {
475                Some(DocumentId::from(effective_initiative_id))
476            },
477            Vec::new(), // blocked_by
478            tags,
479            false, // not archived
480            short_code.clone(),
481        )
482        .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
483
484        // Create parent directory if needed
485        if !task_dir.exists() {
486            fs::create_dir_all(&task_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
487        }
488
489        // Write to file
490        task.to_file(&file_path)
491            .await
492            .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
493
494        Ok(CreationResult {
495            document_id: task.id(),
496            document_type: DocumentType::Task,
497            file_path,
498            short_code,
499        })
500    }
501
502    /// Create a new backlog item (task without parent)
503    pub async fn create_backlog_item(
504        &self,
505        config: DocumentCreationConfig,
506    ) -> Result<CreationResult> {
507        // Generate short code for task (used for both ID and file path)
508        let short_code = self.generate_short_code("task")?;
509
510        // Create backlog directory structure based on tags
511        let backlog_dir = self.determine_backlog_directory(&config.tags);
512        let file_path = backlog_dir.join(format!("{}.md", short_code));
513
514        // Check if backlog item already exists
515        if file_path.exists() {
516            return Err(MetisError::ValidationFailed {
517                message: format!(
518                    "Backlog item with short code '{}' already exists",
519                    short_code
520                ),
521            });
522        }
523
524        // Create backlog item with defaults - no parent, Backlog phase
525        let mut tags = vec![
526            Tag::Label("task".to_string()),
527            Tag::Phase(config.phase.unwrap_or(Phase::Backlog)),
528        ];
529        tags.extend(config.tags);
530
531        let task = Task::new(
532            config.title.clone(),
533            None,       // No parent for backlog items
534            None,       // No parent title for template
535            None,       // No strategy for backlog items
536            None,       // No initiative for backlog items
537            Vec::new(), // blocked_by
538            tags,
539            false, // not archived
540            short_code.clone(),
541        )
542        .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
543
544        // Create parent directory if needed
545        if !backlog_dir.exists() {
546            fs::create_dir_all(&backlog_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
547        }
548
549        // Write to file
550        task.to_file(&file_path)
551            .await
552            .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
553
554        Ok(CreationResult {
555            document_id: task.id(),
556            document_type: DocumentType::Task,
557            file_path,
558            short_code,
559        })
560    }
561
562    /// Determine the backlog directory based on tags
563    fn determine_backlog_directory(&self, tags: &[Tag]) -> PathBuf {
564        let base_backlog_dir = self.workspace_dir.join("backlog");
565
566        // Check for type tags to determine subdirectory
567        for tag in tags {
568            if let Tag::Label(label) = tag {
569                match label.as_str() {
570                    "bug" => return base_backlog_dir.join("bugs"),
571                    "feature" => return base_backlog_dir.join("features"),
572                    "tech-debt" => return base_backlog_dir.join("tech-debt"),
573                    _ => {}
574                }
575            }
576        }
577
578        // Default to general backlog if no specific type found
579        base_backlog_dir
580    }
581
582    /// Create a new ADR document
583    pub async fn create_adr(&self, config: DocumentCreationConfig) -> Result<CreationResult> {
584        // Generate short code for ADR (used for both ID and file path)
585        let short_code = self.generate_short_code("adr")?;
586        let adr_filename = format!("{}.md", short_code);
587        let adrs_dir = self.workspace_dir.join("adrs");
588        let file_path = adrs_dir.join(&adr_filename);
589
590        // Check if ADR already exists
591        if file_path.exists() {
592            return Err(MetisError::ValidationFailed {
593                message: format!("ADR with short code '{}' already exists", short_code),
594            });
595        }
596
597        // Find the next ADR number for the document content (still needed for ADR numbering)
598        let adr_number = self.get_next_adr_number()?;
599
600        // Create ADR with defaults
601        let mut tags = vec![
602            Tag::Label("adr".to_string()),
603            Tag::Phase(config.phase.unwrap_or(Phase::Draft)),
604        ];
605        tags.extend(config.tags);
606
607        let adr = Adr::new(
608            adr_number,
609            config.title.clone(),
610            String::new(), // decision_maker - will be set when transitioning to decided
611            None,          // decision_date - will be set when transitioning to decided
612            config.parent_id,
613            tags,
614            false, // not archived
615            short_code.clone(),
616        )
617        .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
618
619        // Create parent directory
620        fs::create_dir_all(&adrs_dir).map_err(|e| MetisError::FileSystem(e.to_string()))?;
621
622        // Write to file
623        adr.to_file(&file_path)
624            .await
625            .map_err(|e| MetisError::InvalidDocument(e.to_string()))?;
626
627        Ok(CreationResult {
628            document_id: adr.id(),
629            document_type: DocumentType::Adr,
630            file_path,
631            short_code,
632        })
633    }
634
635    /// Get the next ADR number by examining existing ADRs
636    fn get_next_adr_number(&self) -> Result<u32> {
637        let adrs_dir = self.workspace_dir.join("adrs");
638
639        if !adrs_dir.exists() {
640            return Ok(1);
641        }
642
643        let mut max_number = 0;
644        for entry in fs::read_dir(&adrs_dir).map_err(|e| MetisError::FileSystem(e.to_string()))? {
645            let entry = entry.map_err(|e| MetisError::FileSystem(e.to_string()))?;
646            let filename = entry.file_name().to_string_lossy().to_string();
647
648            if filename.ends_with(".md") {
649                // Parse number from filename like "001-title.md"
650                if let Some(number_str) = filename.split('-').next() {
651                    if let Ok(number) = number_str.parse::<u32>() {
652                        max_number = max_number.max(number);
653                    }
654                }
655            }
656        }
657
658        Ok(max_number + 1)
659    }
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use tempfile::tempdir;
666
667    #[tokio::test]
668    async fn test_create_vision_document() {
669        let temp_dir = tempdir().unwrap();
670        let workspace_dir = temp_dir.path().join(".metis");
671        fs::create_dir_all(&workspace_dir).unwrap();
672
673        // Create and initialize database with proper schema
674        let db_path = workspace_dir.join("metis.db");
675        let _db = crate::Database::new(&db_path.to_string_lossy()).unwrap();
676
677        // Set up project prefix in configuration
678        let mut config_repo = ConfigurationRepository::new(
679            SqliteConnection::establish(&db_path.to_string_lossy()).unwrap(),
680        );
681        config_repo.set_project_prefix("TEST").unwrap();
682
683        let service = DocumentCreationService::new(&workspace_dir);
684        let config = DocumentCreationConfig {
685            title: "Test Vision".to_string(),
686            description: Some("A test vision document".to_string()),
687            parent_id: None,
688            tags: vec![],
689            phase: None,
690            complexity: None,
691            risk_level: None,
692        };
693
694        let result = service.create_vision(config).await.unwrap();
695
696        assert_eq!(result.document_type, DocumentType::Vision);
697        assert!(result.file_path.exists());
698
699        // Verify we can read it back
700        let vision = Vision::from_file(&result.file_path).await.unwrap();
701        assert_eq!(vision.title(), "Test Vision");
702    }
703
704    #[tokio::test]
705    async fn test_create_strategy_document() {
706        let temp_dir = tempdir().unwrap();
707        let workspace_dir = temp_dir.path().join(".metis");
708        fs::create_dir_all(&workspace_dir).unwrap();
709
710        // Create and initialize database with proper schema
711        let db_path = workspace_dir.join("metis.db");
712        let _db = crate::Database::new(&db_path.to_string_lossy()).unwrap();
713
714        // Set up project prefix in configuration
715        let mut config_repo = ConfigurationRepository::new(
716            SqliteConnection::establish(&db_path.to_string_lossy()).unwrap(),
717        );
718        config_repo.set_project_prefix("TEST").unwrap();
719
720        let service = DocumentCreationService::new(&workspace_dir);
721        let config = DocumentCreationConfig {
722            title: "Test Strategy".to_string(),
723            description: Some("A test strategy document".to_string()),
724            parent_id: None,
725            tags: vec![],
726            phase: None,
727            complexity: None,
728            risk_level: None,
729        };
730
731        let result = service.create_strategy(config).await.unwrap();
732
733        assert_eq!(result.document_type, DocumentType::Strategy);
734        assert!(result.file_path.exists());
735
736        // Verify we can read it back
737        let strategy = Strategy::from_file(&result.file_path).await.unwrap();
738        assert_eq!(strategy.title(), "Test Strategy");
739    }
740
741    #[tokio::test]
742    async fn test_create_initiative_document() {
743        let temp_dir = tempdir().unwrap();
744        let workspace_dir = temp_dir.path().join(".metis");
745        fs::create_dir_all(&workspace_dir).unwrap();
746
747        // Create and initialize database with proper schema
748        let db_path = workspace_dir.join("metis.db");
749        let _db = crate::Database::new(&db_path.to_string_lossy()).unwrap();
750
751        // Set up project prefix in configuration
752        let mut config_repo = ConfigurationRepository::new(
753            SqliteConnection::establish(&db_path.to_string_lossy()).unwrap(),
754        );
755        config_repo.set_project_prefix("TEST").unwrap();
756
757        let service = DocumentCreationService::new(&workspace_dir);
758
759        // First create a parent strategy
760        let strategy_config = DocumentCreationConfig {
761            title: "Parent Strategy".to_string(),
762            description: Some("A parent strategy".to_string()),
763            parent_id: None,
764            tags: vec![],
765            phase: None,
766            complexity: None,
767            risk_level: None,
768        };
769        let strategy_result = service.create_strategy(strategy_config).await.unwrap();
770        let strategy_id = strategy_result.short_code.clone();
771
772        // Sync the strategy to database so it can be found by the initiative creation
773        let db = crate::Database::new(&db_path.to_string_lossy()).unwrap();
774        let mut db_service =
775            crate::application::services::DatabaseService::new(db.repository().unwrap());
776        let mut sync_service = crate::application::services::SyncService::new(&mut db_service);
777        sync_service
778            .import_from_file(&strategy_result.file_path)
779            .await
780            .unwrap();
781
782        // Now create an initiative
783        let initiative_config = DocumentCreationConfig {
784            title: "Test Initiative".to_string(),
785            description: Some("A test initiative document".to_string()),
786            parent_id: Some(strategy_result.document_id),
787            tags: vec![],
788            phase: None,
789            complexity: None,
790            risk_level: None,
791        };
792
793        let result = service
794            .create_initiative(initiative_config, &strategy_id)
795            .await
796            .unwrap();
797
798        assert_eq!(result.document_type, DocumentType::Initiative);
799        assert!(result.file_path.exists());
800
801        // Verify we can read it back
802        let initiative = Initiative::from_file(&result.file_path).await.unwrap();
803        assert_eq!(initiative.title(), "Test Initiative");
804    }
805
806
807    #[tokio::test]
808    async fn test_get_next_adr_number() {
809        let temp_dir = tempdir().unwrap();
810        let workspace_dir = temp_dir.path().join(".metis");
811        let adrs_dir = workspace_dir.join("adrs");
812        fs::create_dir_all(&adrs_dir).unwrap();
813
814        let service = DocumentCreationService::new(&workspace_dir);
815
816        // Should start at 1 when no ADRs exist
817        assert_eq!(service.get_next_adr_number().unwrap(), 1);
818
819        // Create some ADR files
820        fs::write(adrs_dir.join("001-first-adr.md"), "content").unwrap();
821        fs::write(adrs_dir.join("002-second-adr.md"), "content").unwrap();
822
823        // Should return 3 as next number
824        assert_eq!(service.get_next_adr_number().unwrap(), 3);
825    }
826
827    // Flexible flight levels tests
828
829    fn setup_test_service_temp() -> (DocumentCreationService, tempfile::TempDir) {
830        let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory");
831        let workspace_dir = temp_dir.path().join(".metis");
832        fs::create_dir_all(&workspace_dir).unwrap();
833
834        // Create and initialize database with proper schema
835        let db_path = workspace_dir.join("metis.db");
836        let _db = crate::Database::new(&db_path.to_string_lossy()).unwrap();
837
838        // Set up project prefix in configuration
839        let mut config_repo = ConfigurationRepository::new(
840            SqliteConnection::establish(&db_path.to_string_lossy()).unwrap(),
841        );
842        config_repo.set_project_prefix("TEST").unwrap();
843
844        let service = DocumentCreationService::new(&workspace_dir);
845        (service, temp_dir)
846    }
847
848    #[tokio::test]
849    async fn test_create_initiative_full_configuration() {
850        let (service, temp) = setup_test_service_temp();
851        let flight_config = FlightLevelConfig::full();
852
853        // First create a strategy
854        let strategy_config = DocumentCreationConfig {
855            title: "Test Strategy".to_string(),
856            description: None,
857            parent_id: None,
858            tags: vec![],
859            phase: None,
860            complexity: None,
861            risk_level: None,
862        };
863
864        let strategy_result = service.create_strategy(strategy_config).await.unwrap();
865
866        // Sync the strategy to database so it can be found by the initiative creation
867        let db_path = temp.path().join(".metis/metis.db");
868        let db = crate::Database::new(&db_path.to_string_lossy()).unwrap();
869        let mut db_service =
870            crate::application::services::DatabaseService::new(db.repository().unwrap());
871        let mut sync_service = crate::application::services::SyncService::new(&mut db_service);
872        sync_service
873            .import_from_file(&strategy_result.file_path)
874            .await
875            .unwrap();
876
877        // Now create an initiative under the strategy
878        let initiative_config = DocumentCreationConfig {
879            title: "Test Initiative".to_string(),
880            description: None,
881            parent_id: None,
882            tags: vec![],
883            phase: None,
884            complexity: None,
885            risk_level: None,
886        };
887
888        let result = service
889            .create_initiative_with_config(
890                initiative_config,
891                &strategy_result.short_code,
892                &flight_config,
893            )
894            .await
895            .unwrap();
896
897        assert_eq!(result.document_type, DocumentType::Initiative);
898        assert!(result.file_path.exists());
899
900        // Verify the path structure for full configuration
901        assert!(result.file_path.to_string_lossy().contains("strategies"));
902        assert!(result.file_path.to_string_lossy().contains("initiatives"));
903    }
904
905    #[tokio::test]
906    async fn test_create_initiative_streamlined_configuration() {
907        let (service, _temp) = setup_test_service_temp();
908        let flight_config = FlightLevelConfig::streamlined();
909
910        let initiative_config = DocumentCreationConfig {
911            title: "Test Initiative".to_string(),
912            description: None,
913            parent_id: None,
914            tags: vec![],
915            phase: None,
916            complexity: None,
917            risk_level: None,
918        };
919
920        // In streamlined config, we pass "NULL" as strategy_id
921        let result = service
922            .create_initiative_with_config(initiative_config, "NULL", &flight_config)
923            .await
924            .unwrap();
925
926        assert_eq!(result.document_type, DocumentType::Initiative);
927        assert!(result.file_path.exists());
928
929        // Verify the NULL-based path structure for streamlined configuration
930        assert!(result.file_path.to_string_lossy().contains("initiatives"));
931        assert!(result
932            .file_path
933            .to_string_lossy()
934            .contains("strategies/NULL"));
935    }
936
937    #[tokio::test]
938    async fn test_create_initiative_disabled_in_direct_configuration() {
939        let (service, _temp) = setup_test_service_temp();
940        let flight_config = FlightLevelConfig::direct();
941
942        let initiative_config = DocumentCreationConfig {
943            title: "Test Initiative".to_string(),
944            description: None,
945            parent_id: None,
946            tags: vec![],
947            phase: None,
948            complexity: None,
949            risk_level: None,
950        };
951
952        // In direct config, initiatives are disabled
953        let result = service
954            .create_initiative_with_config(initiative_config, "NULL", &flight_config)
955            .await;
956
957        assert!(result.is_err());
958        assert!(result
959            .unwrap_err()
960            .to_string()
961            .contains("Initiative creation is disabled"));
962    }
963
964    #[tokio::test]
965    async fn test_create_task_direct_configuration() {
966        let (service, _temp) = setup_test_service_temp();
967        let flight_config = FlightLevelConfig::direct();
968
969        let task_config = DocumentCreationConfig {
970            title: "Test Task".to_string(),
971            description: None,
972            parent_id: None,
973            tags: vec![],
974            phase: None,
975            complexity: None,
976            risk_level: None,
977        };
978
979        // In direct config, tasks go directly under workspace
980        let result = service
981            .create_task_with_config(task_config, "NULL", "NULL", &flight_config)
982            .await
983            .unwrap();
984
985        assert_eq!(result.document_type, DocumentType::Task);
986        assert!(result.file_path.exists());
987
988        // Verify the NULL-based path structure for direct configuration
989        assert!(result.file_path.to_string_lossy().contains("tasks"));
990        assert!(result
991            .file_path
992            .to_string_lossy()
993            .contains("strategies/NULL/initiatives/NULL"));
994    }
995}