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