metis_core/
project.rs

1//! Project initialization functionality
2
3use crate::{DocumentContext, DocumentStore, DocumentType, MetisError, Result, TemplateEngine};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7/// Configuration for initializing a new Metis project
8#[derive(Debug, Clone)]
9pub struct ProjectConfig {
10    pub name: String,
11    pub description: Option<String>,
12    pub root_path: PathBuf,
13}
14
15/// Metadata returned after successful project initialization
16#[derive(Debug, Clone)]
17pub struct ProjectMetadata {
18    pub project_path: PathBuf,
19    pub database_path: PathBuf,
20}
21
22/// Initialize a new Metis project (idempotent and non-destructive)
23pub async fn initialize_project(config: ProjectConfig) -> Result<ProjectMetadata> {
24    let project_path = config.root_path.join("metis");
25    let database_path = project_path.join(".metis.db");
26
27    // 1. Validate project name (filesystem safety)
28    if !is_valid_project_name(&config.name) {
29        return Err(MetisError::ValidationFailed {
30            message: format!("Invalid project name '{}'. Use only alphanumeric characters, hyphens, and underscores.", config.name),
31        });
32    }
33
34    // 2. Validate parent directory exists and is writable
35    if !config.root_path.exists() {
36        return Err(MetisError::ValidationFailed {
37            message: format!(
38                "Parent directory does not exist: {}",
39                config.root_path.display()
40            ),
41        });
42    }
43
44    if !config.root_path.is_dir() {
45        return Err(MetisError::ValidationFailed {
46            message: format!("Path is not a directory: {}", config.root_path.display()),
47        });
48    }
49
50    // 3. Create metis directory if it doesn't exist
51    if !project_path.exists() {
52        fs::create_dir_all(&project_path).map_err(|e| MetisError::ValidationFailed {
53            message: format!("Failed to create metis directory: {}", e),
54        })?;
55    }
56
57    // 4. Test write permissions by creating and removing a temporary file
58    let temp_file = project_path.join(".metis_temp_test");
59    if let Err(e) = fs::write(&temp_file, "") {
60        return Err(MetisError::ValidationFailed {
61            message: format!("Directory is not writable: {}", e),
62        });
63    }
64    let _ = fs::remove_file(temp_file); // Ignore errors on cleanup
65
66    // 5. Create directory structure if it doesn't exist
67    create_directory_structure(&project_path)?;
68
69    // 6. Initialize database if it doesn't exist
70    if !database_path.exists() {
71        let database_url = format!("sqlite:{}", database_path.display());
72        let _store = DocumentStore::new(&database_url).await?;
73    }
74
75    // 7. Create initial vision document if it doesn't exist
76    create_initial_vision(&project_path, &config.name, config.description.as_deref())?;
77
78    Ok(ProjectMetadata {
79        project_path,
80        database_path,
81    })
82}
83
84/// Create the standard Metis directory structure (idempotent)
85fn create_directory_structure(project_path: &Path) -> Result<()> {
86    let strategies_dir = project_path.join("strategies");
87    let decisions_dir = project_path.join("decisions");
88
89    // create_dir_all is already idempotent - it won't fail if directories exist
90    fs::create_dir_all(&strategies_dir).map_err(|e| MetisError::ValidationFailed {
91        message: format!("Failed to create strategies directory: {}", e),
92    })?;
93
94    fs::create_dir_all(&decisions_dir).map_err(|e| MetisError::ValidationFailed {
95        message: format!("Failed to create decisions directory: {}", e),
96    })?;
97
98    Ok(())
99}
100
101/// Create the initial vision document using the template system (non-destructive)
102fn create_initial_vision(
103    project_path: &Path,
104    project_name: &str,
105    description: Option<&str>,
106) -> Result<()> {
107    let vision_path = project_path.join("vision.md");
108
109    // Only create vision document if it doesn't already exist
110    if vision_path.exists() {
111        return Ok(());
112    }
113
114    let template_engine = TemplateEngine::new()?;
115
116    // Create context for vision document
117    let vision_context = DocumentContext::new(format!("{} Vision", project_name));
118
119    // Render the vision document
120    let vision_content = template_engine.render_document(&DocumentType::Vision, &vision_context)?;
121
122    // Customize content if description is provided
123    let final_content = if let Some(desc) = description {
124        // Replace the placeholder purpose section with the provided description
125        vision_content.replace("{Why this vision exists and what it aims to achieve}", desc)
126    } else {
127        vision_content
128    };
129
130    // Write vision document to project root
131    fs::write(&vision_path, final_content).map_err(|e| MetisError::ValidationFailed {
132        message: format!("Failed to create vision document: {}", e),
133    })?;
134
135    Ok(())
136}
137
138/// Validate that a project name is safe for filesystem use
139fn is_valid_project_name(name: &str) -> bool {
140    if name.is_empty() || name.len() > 255 {
141        return false;
142    }
143
144    // Only allow alphanumeric characters, hyphens, underscores, and spaces
145    name.chars()
146        .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == ' ')
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use std::path::PathBuf;
153    use tempfile::TempDir;
154
155    #[test]
156    fn test_is_valid_project_name() {
157        assert!(is_valid_project_name("my-project"));
158        assert!(is_valid_project_name("my_project"));
159        assert!(is_valid_project_name("MyProject123"));
160        assert!(is_valid_project_name("My Project"));
161
162        assert!(!is_valid_project_name(""));
163        assert!(!is_valid_project_name("my/project"));
164        assert!(!is_valid_project_name("my\\project"));
165        assert!(!is_valid_project_name("my<project>"));
166        assert!(!is_valid_project_name("my|project"));
167
168        // Test very long name
169        let long_name = "a".repeat(256);
170        assert!(!is_valid_project_name(&long_name));
171    }
172
173    #[tokio::test]
174    async fn test_initialize_project_success() {
175        let temp_dir = TempDir::new().unwrap();
176        let project_path = temp_dir.path().to_path_buf();
177
178        let config = ProjectConfig {
179            name: "Test Project".to_string(),
180            description: Some("A test project for validation".to_string()),
181            root_path: project_path.clone(),
182        };
183
184        let result = initialize_project(config).await;
185        assert!(result.is_ok());
186
187        let metadata = result.unwrap();
188        assert_eq!(metadata.project_path, project_path.join("metis"));
189        assert_eq!(
190            metadata.database_path,
191            project_path.join("metis").join(".metis.db")
192        );
193
194        // Verify directory structure was created
195        assert!(project_path.join("metis").join("strategies").exists());
196        assert!(project_path.join("metis").join("decisions").exists());
197
198        // Verify database was created
199        assert!(project_path.join("metis").join(".metis.db").exists());
200
201        // Verify vision document was created
202        let vision_path = project_path.join("metis").join("vision.md");
203        assert!(vision_path.exists());
204
205        let vision_content = fs::read_to_string(vision_path).unwrap();
206        assert!(vision_content.contains("Test Project Vision"));
207        assert!(vision_content.contains("A test project for validation"));
208    }
209
210    #[tokio::test]
211    async fn test_initialize_project_already_exists() {
212        let temp_dir = TempDir::new().unwrap();
213        let project_path = temp_dir.path().to_path_buf();
214
215        // Create existing metis directory and .metis.db file
216        fs::create_dir_all(project_path.join("metis")).unwrap();
217        fs::write(project_path.join("metis").join(".metis.db"), "").unwrap();
218
219        let config = ProjectConfig {
220            name: "Test Project".to_string(),
221            description: None,
222            root_path: project_path.clone(),
223        };
224
225        // Should succeed because initialization is now idempotent
226        let result = initialize_project(config).await;
227        assert!(result.is_ok());
228
229        let metadata = result.unwrap();
230        assert_eq!(metadata.project_path, project_path.join("metis"));
231        assert_eq!(
232            metadata.database_path,
233            project_path.join("metis").join(".metis.db")
234        );
235    }
236
237    #[tokio::test]
238    async fn test_initialize_project_twice() {
239        let temp_dir = TempDir::new().unwrap();
240        let project_path = temp_dir.path().to_path_buf();
241
242        let config = ProjectConfig {
243            name: "Test Project".to_string(),
244            description: Some("A test project for double initialization".to_string()),
245            root_path: project_path.clone(),
246        };
247
248        // First initialization should succeed
249        let result1 = initialize_project(config.clone()).await;
250        assert!(result1.is_ok());
251
252        // Verify it was created properly
253        assert!(project_path.join("metis").join(".metis.db").exists());
254        assert!(project_path.join("metis").join("vision.md").exists());
255
256        // Second initialization should succeed (idempotent)
257        let result2 = initialize_project(config).await;
258        assert!(result2.is_ok());
259
260        let metadata2 = result2.unwrap();
261        assert_eq!(metadata2.project_path, project_path.join("metis"));
262        assert_eq!(
263            metadata2.database_path,
264            project_path.join("metis").join(".metis.db")
265        );
266    }
267
268    #[tokio::test]
269    async fn test_initialize_project_invalid_name() {
270        let temp_dir = TempDir::new().unwrap();
271        let project_path = temp_dir.path().to_path_buf();
272
273        let config = ProjectConfig {
274            name: "invalid/name".to_string(), // Contains slash
275            description: None,
276            root_path: project_path.clone(),
277        };
278
279        let result = initialize_project(config).await;
280        assert!(result.is_err());
281
282        if let Err(MetisError::ValidationFailed { message }) = result {
283            assert!(message.contains("Invalid project name"));
284        } else {
285            panic!("Expected ValidationFailed error");
286        }
287    }
288
289    #[tokio::test]
290    async fn test_initialize_project_nonexistent_directory() {
291        let nonexistent_path = PathBuf::from("/nonexistent/directory");
292
293        let config = ProjectConfig {
294            name: "Test Project".to_string(),
295            description: None,
296            root_path: nonexistent_path,
297        };
298
299        let result = initialize_project(config).await;
300        assert!(result.is_err());
301
302        if let Err(MetisError::ValidationFailed { message }) = result {
303            assert!(message.contains("Parent directory does not exist"));
304        } else {
305            panic!("Expected ValidationFailed error");
306        }
307    }
308
309    #[tokio::test]
310    async fn test_initialize_project_without_description() {
311        let temp_dir = TempDir::new().unwrap();
312        let project_path = temp_dir.path().to_path_buf();
313
314        let config = ProjectConfig {
315            name: "Simple Project".to_string(),
316            description: None,
317            root_path: project_path.clone(),
318        };
319
320        let result = initialize_project(config).await;
321        assert!(result.is_ok());
322
323        // Verify vision document was created with default content
324        let vision_path = project_path.join("metis").join("vision.md");
325        assert!(vision_path.exists());
326
327        let vision_content = fs::read_to_string(vision_path).unwrap();
328        assert!(vision_content.contains("Simple Project Vision"));
329        // Should contain the default placeholder text
330        assert!(vision_content.contains("{Why this vision exists and what it aims to achieve}"));
331    }
332
333    #[test]
334    fn test_create_directory_structure() {
335        let temp_dir = TempDir::new().unwrap();
336        let project_path = temp_dir.path();
337
338        let result = create_directory_structure(project_path);
339        assert!(result.is_ok());
340
341        assert!(project_path.join("strategies").exists());
342        assert!(project_path.join("decisions").exists());
343        assert!(project_path.join("strategies").is_dir());
344        assert!(project_path.join("decisions").is_dir());
345    }
346}