metis_docs_cli/commands/
init.rs

1use crate::workspace;
2use anyhow::Result;
3use clap::Args;
4use metis_core::{
5    application::services::workspace::WorkspaceInitializationService,
6    domain::configuration::FlightLevelConfig,
7    Database,
8};
9
10#[derive(Args)]
11pub struct InitCommand {
12    /// Project name for the vision document
13    #[arg(short, long)]
14    pub name: Option<String>,
15    /// Project prefix for document short codes, up to 6 characters (e.g., PROJ, ACME, TEST)
16    #[arg(short = 'P', long)]
17    pub prefix: Option<String>,
18    /// Configuration preset (full, streamlined, direct). Default: streamlined
19    #[arg(short, long)]
20    pub preset: Option<String>,
21    /// Enable/disable strategies (true/false)
22    #[arg(long)]
23    pub strategies: Option<bool>,
24    /// Enable/disable initiatives (true/false)
25    #[arg(long)]
26    pub initiatives: Option<bool>,
27}
28
29impl InitCommand {
30    pub async fn execute(&self) -> Result<()> {
31        // Check if workspace already exists
32        let (workspace_exists, _) = workspace::has_metis_vault();
33        if workspace_exists {
34            println!("Metis workspace already exists in this directory");
35            return Ok(());
36        }
37
38        // Get current directory for workspace creation
39        let current_dir = std::env::current_dir()?;
40
41        // Determine project name and prefix
42        let project_name = self.name.as_deref().unwrap_or("Project Vision");
43        let project_prefix = self.determine_project_prefix(project_name);
44
45        // Use WorkspaceInitializationService to create workspace
46        let result = WorkspaceInitializationService::initialize_workspace_with_prefix(
47            &current_dir,
48            project_name,
49            Some(&project_prefix),
50        )
51        .await
52        .map_err(|e| anyhow::anyhow!("Failed to initialize workspace: {}", e))?;
53
54        // If custom flight level config was specified, update it
55        let flight_config = self.determine_flight_config()?;
56        let db = Database::new(result.database_path.to_str().unwrap())
57            .map_err(|e| anyhow::anyhow!("Failed to open database: {}", e))?;
58        let mut config_repo = db
59            .configuration_repository()
60            .map_err(|e| anyhow::anyhow!("Failed to create configuration repository: {}", e))?;
61
62        // Update flight level config if it differs from default
63        let current_config = config_repo.get_flight_level_config()
64            .map_err(|e| anyhow::anyhow!("Failed to get flight level config: {}", e))?;
65        if flight_config != current_config {
66            config_repo
67                .set_flight_level_config(&flight_config)
68                .map_err(|e| anyhow::anyhow!("Failed to set flight level configuration: {}", e))?;
69
70            // Update config.toml to match
71            let config_file_path = result.metis_dir.join("config.toml");
72            let config_file = metis_core::domain::configuration::ConfigFile::new(
73                project_prefix.clone(),
74                flight_config.clone()
75            ).map_err(|e| anyhow::anyhow!("Failed to create config file: {}", e))?;
76            config_file.save(&config_file_path)
77                .map_err(|e| anyhow::anyhow!("Failed to save config.toml: {}", e))?;
78        }
79
80        println!("✓ Initialized Metis workspace in {}", current_dir.display());
81        println!("✓ Created vision.md with project template");
82        println!("✓ Created config.toml with project settings");
83        println!("✓ Set project prefix: {}", project_prefix);
84        println!(
85            "✓ Set flight level configuration: {}",
86            flight_config.preset_name()
87        );
88
89        Ok(())
90    }
91
92    /// Determine the project prefix from command arguments or project name
93    fn determine_project_prefix(&self, project_name: &str) -> String {
94        if let Some(prefix) = &self.prefix {
95            // Use explicitly provided prefix, but limit to 6 characters
96            let truncated = prefix.to_uppercase();
97            if truncated.len() > 6 {
98                truncated.chars().take(6).collect()
99            } else {
100                truncated
101            }
102        } else if cfg!(test) {
103            // Use "TEST" in test mode
104            "TEST".to_string()
105        } else {
106            // Extract first 6 uppercase letters from project name, or use "PROJ" as fallback
107            project_name
108                .chars()
109                .filter(|c| c.is_alphabetic())
110                .map(|c| c.to_uppercase().collect::<String>())
111                .collect::<String>()
112                .get(0..6.min(project_name.len()))
113                .unwrap_or("PROJ")
114                .to_string()
115        }
116    }
117
118    /// Determine the flight level configuration based on command arguments
119    fn determine_flight_config(&self) -> Result<FlightLevelConfig> {
120        if let Some(preset_name) = &self.preset {
121            // Use specified preset
122            match preset_name.as_str() {
123                "full" => Ok(FlightLevelConfig::full()),
124                "streamlined" => Ok(FlightLevelConfig::streamlined()),
125                "direct" => Ok(FlightLevelConfig::direct()),
126                _ => {
127                    anyhow::bail!(
128                        "Invalid preset '{}'. Valid presets are: full, streamlined, direct",
129                        preset_name
130                    );
131                }
132            }
133        } else if self.strategies.is_some() || self.initiatives.is_some() {
134            // Use custom configuration, with streamlined as default base
135            let default_config = FlightLevelConfig::streamlined();
136            let strategies_enabled = self.strategies.unwrap_or(default_config.strategies_enabled);
137            let initiatives_enabled = self
138                .initiatives
139                .unwrap_or(default_config.initiatives_enabled);
140
141            FlightLevelConfig::new(strategies_enabled, initiatives_enabled)
142                .map_err(|e| anyhow::anyhow!("Invalid configuration: {}", e))
143        } else {
144            // Default to streamlined preset
145            Ok(FlightLevelConfig::streamlined())
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use std::fs;
154    use tempfile::tempdir;
155
156    #[tokio::test]
157    async fn test_init_command_creates_workspace() {
158        let temp_dir = tempdir().unwrap();
159        let original_dir = std::env::current_dir().ok();
160
161        // Change to temp directory
162        std::env::set_current_dir(temp_dir.path()).unwrap();
163
164        // Run init command
165        let cmd = InitCommand {
166            name: Some("Test Project".to_string()),
167            preset: None,
168            strategies: None,
169            initiatives: None,
170            prefix: None,
171        };
172
173        let result = cmd.execute().await;
174        assert!(result.is_ok());
175
176        // Verify .metis directory was created
177        let metis_dir = temp_dir.path().join(".metis");
178        assert!(metis_dir.exists());
179        assert!(metis_dir.is_dir());
180
181        // Verify database was created
182        let db_path = metis_dir.join("metis.db");
183        assert!(db_path.exists());
184        assert!(db_path.is_file());
185
186        // Verify strategies directory was created
187        let strategies_dir = metis_dir.join("strategies");
188        assert!(strategies_dir.exists());
189        assert!(strategies_dir.is_dir());
190
191        // Verify vision.md was created
192        let vision_path = metis_dir.join("vision.md");
193        assert!(vision_path.exists());
194        assert!(vision_path.is_file());
195
196        // Verify vision.md content
197        let vision_content = fs::read_to_string(&vision_path).unwrap();
198        assert!(vision_content.contains("Test Project"));
199        assert!(vision_content.contains("#vision"));
200        assert!(vision_content.contains("#phase/draft"));
201        assert!(vision_content.contains("archived: false"));
202
203        // Verify template was rendered
204        assert!(vision_content.contains("# Test Project Vision"));
205        assert!(vision_content.contains("## Purpose"));
206        assert!(vision_content.contains("## Current State"));
207        assert!(vision_content.contains("## Future State"));
208        assert!(vision_content.contains("## Success Criteria"));
209        assert!(vision_content.contains("## Principles"));
210        assert!(vision_content.contains("## Constraints"));
211
212        // Verify config.toml was created
213        let config_path = metis_dir.join("config.toml");
214        assert!(config_path.exists(), "config.toml should be created");
215        assert!(config_path.is_file());
216
217        // Verify config.toml content
218        let config_content = fs::read_to_string(&config_path).unwrap();
219        assert!(config_content.contains("[project]"));
220        assert!(config_content.contains("prefix = \"TEST\""));
221        assert!(config_content.contains("[flight_levels]"));
222
223        // Restore original directory
224        if let Some(original) = original_dir {
225            let _ = std::env::set_current_dir(&original);
226        }
227    }
228
229    #[tokio::test]
230    async fn test_init_command_workspace_already_exists() {
231        let temp_dir = tempdir().unwrap();
232        let original_dir = std::env::current_dir().ok();
233        let metis_dir = temp_dir.path().join(".metis");
234        let db_path = metis_dir.join("metis.db");
235
236        // Pre-create workspace
237        fs::create_dir_all(&metis_dir).unwrap();
238        fs::write(&db_path, "existing").unwrap();
239
240        // Change to temp directory
241        std::env::set_current_dir(temp_dir.path()).unwrap();
242
243        // Run init command
244        let cmd = InitCommand {
245            name: Some("Test Project".to_string()),
246            preset: None,
247            strategies: None,
248            initiatives: None,
249            prefix: None,
250        };
251
252        let result = cmd.execute().await;
253        assert!(result.is_ok());
254
255        // Verify existing database wasn't overwritten
256        let db_content = fs::read_to_string(&db_path).unwrap();
257        assert_eq!(db_content, "existing");
258
259        // Restore original directory
260        if let Some(original) = original_dir {
261            let _ = std::env::set_current_dir(&original);
262        }
263    }
264
265    #[tokio::test]
266    async fn test_init_command_default_name() {
267        let temp_dir = tempdir().unwrap();
268        let original_dir = std::env::current_dir().ok();
269
270        // Change to temp directory
271        std::env::set_current_dir(temp_dir.path()).unwrap();
272
273        // Run init command without name
274        let cmd = InitCommand {
275            name: None,
276            preset: None,
277            strategies: None,
278            initiatives: None,
279            prefix: None,
280        };
281
282        let result = cmd.execute().await;
283        assert!(result.is_ok());
284
285        // Verify vision.md was created with default name
286        let vision_path = temp_dir.path().join(".metis").join("vision.md");
287        let vision_content = fs::read_to_string(&vision_path).unwrap();
288        assert!(vision_content.contains("Project Vision"));
289
290        // Restore original directory
291        if let Some(original) = original_dir {
292            let _ = std::env::set_current_dir(&original);
293        }
294    }
295
296    #[tokio::test]
297    async fn test_init_command_with_preset() {
298        let temp_dir = tempdir().unwrap();
299        let original_dir = std::env::current_dir().ok();
300
301        // Change to temp directory
302        std::env::set_current_dir(temp_dir.path()).unwrap();
303
304        // Run init command with full preset
305        let cmd = InitCommand {
306            name: Some("Test Project".to_string()),
307            preset: Some("full".to_string()),
308            strategies: None,
309            initiatives: None,
310            prefix: None,
311        };
312
313        let result = cmd.execute().await;
314        assert!(result.is_ok());
315
316        // Verify workspace was created
317        let metis_dir = temp_dir.path().join(".metis");
318        assert!(metis_dir.exists());
319
320        // Verify configuration was set
321        use metis_core::Database;
322        let db_path = metis_dir.join("metis.db");
323        let db = Database::new(db_path.to_str().unwrap()).unwrap();
324        let mut config_repo = db.configuration_repository().unwrap();
325        let config = config_repo.get_flight_level_config().unwrap();
326
327        assert_eq!(
328            config,
329            metis_core::domain::configuration::FlightLevelConfig::full()
330        );
331
332        // Restore original directory
333        if let Some(original) = original_dir {
334            let _ = std::env::set_current_dir(&original);
335        }
336    }
337
338    #[tokio::test]
339    async fn test_init_command_with_custom_flags() {
340        let temp_dir = tempdir().unwrap();
341        let original_dir = std::env::current_dir().ok();
342
343        // Change to temp directory
344        std::env::set_current_dir(temp_dir.path()).unwrap();
345
346        // Run init command with custom flags (strategies disabled, initiatives enabled)
347        let cmd = InitCommand {
348            name: Some("Test Project".to_string()),
349            preset: None,
350            strategies: Some(false),
351            initiatives: Some(true),
352            prefix: None,
353        };
354
355        let result = cmd.execute().await;
356        assert!(result.is_ok());
357
358        // Verify configuration was set
359        use metis_core::Database;
360        let metis_dir = temp_dir.path().join(".metis");
361        let db_path = metis_dir.join("metis.db");
362        let db = Database::new(db_path.to_str().unwrap()).unwrap();
363        let mut config_repo = db.configuration_repository().unwrap();
364        let config = config_repo.get_flight_level_config().unwrap();
365
366        assert!(!config.strategies_enabled);
367        assert!(config.initiatives_enabled);
368
369        // Restore original directory
370        if let Some(original) = original_dir {
371            let _ = std::env::set_current_dir(&original);
372        }
373    }
374
375    #[tokio::test]
376    async fn test_init_command_default_streamlined() {
377        let temp_dir = tempdir().unwrap();
378        let original_dir = std::env::current_dir().ok();
379
380        // Change to temp directory
381        std::env::set_current_dir(temp_dir.path()).unwrap();
382
383        // Run init command with no preset specified (should default to streamlined)
384        let cmd = InitCommand {
385            name: Some("Test Project".to_string()),
386            preset: None,
387            strategies: None,
388            initiatives: None,
389            prefix: None,
390        };
391
392        let result = cmd.execute().await;
393        assert!(result.is_ok());
394
395        // Verify configuration defaults to streamlined
396        use metis_core::Database;
397        let metis_dir = temp_dir.path().join(".metis");
398        let db_path = metis_dir.join("metis.db");
399        let db = Database::new(db_path.to_str().unwrap()).unwrap();
400        let mut config_repo = db.configuration_repository().unwrap();
401        let config = config_repo.get_flight_level_config().unwrap();
402
403        assert_eq!(
404            config,
405            metis_core::domain::configuration::FlightLevelConfig::streamlined()
406        );
407
408        // Restore original directory
409        if let Some(original) = original_dir {
410            let _ = std::env::set_current_dir(&original);
411        }
412    }
413
414    #[tokio::test]
415    async fn test_init_command_invalid_preset() {
416        let temp_dir = tempdir().unwrap();
417        let original_dir = std::env::current_dir().ok();
418
419        // Change to temp directory
420        std::env::set_current_dir(temp_dir.path()).unwrap();
421
422        // Run init command with invalid preset
423        let cmd = InitCommand {
424            name: Some("Test Project".to_string()),
425            preset: Some("invalid".to_string()),
426            strategies: None,
427            initiatives: None,
428            prefix: None,
429        };
430
431        let result = cmd.execute().await;
432        assert!(result.is_err());
433        assert!(result.unwrap_err().to_string().contains("Invalid preset"));
434
435        // Restore original directory
436        if let Some(original) = original_dir {
437            let _ = std::env::set_current_dir(&original);
438        }
439    }
440}