metis_core/application/services/document/
deletion.rs

1use crate::Result;
2use std::path::Path;
3
4/// Service for recursive document deletion
5///
6/// Handles the complete deletion of a document and all its children:
7/// 1. Identifies document type from path
8/// 2. For strategies/initiatives: rm -r the folder
9/// 3. For tasks: delete the file
10/// 4. Caller can sync to update database
11pub struct DeletionService {}
12
13impl Default for DeletionService {
14    fn default() -> Self {
15        Self::new()
16    }
17}
18
19impl DeletionService {
20    pub fn new() -> Self {
21        Self {}
22    }
23
24    /// Delete a document and all its children recursively
25    pub async fn delete_document_recursive(&self, filepath: &str) -> Result<DeletionResult> {
26        let file_path = Path::new(filepath);
27
28        if !file_path.exists() {
29            return Ok(DeletionResult {
30                deleted_files: vec![],
31                cleaned_directories: vec![],
32            });
33        }
34
35        let mut deleted_files = Vec::new();
36        let mut cleaned_directories = Vec::new();
37
38        // For documents structured as "parent-dir/document.md",
39        // we need to delete the entire parent directory
40        if let Some(parent_dir) = file_path.parent() {
41            // Check if parent is not the workspace root and is a directory
42            if parent_dir != Path::new(".") && parent_dir != Path::new("") && parent_dir.is_dir() {
43                // For strategy/initiative documents, delete the entire parent directory
44                // This handles cases like "strategy-id/strategy.md" -> delete "strategy-id/"
45                if file_path.file_name() == Some(std::ffi::OsStr::new("strategy.md"))
46                    || file_path.file_name() == Some(std::ffi::OsStr::new("initiative.md"))
47                {
48                    Self::remove_directory_recursive(
49                        parent_dir,
50                        &mut deleted_files,
51                        &mut cleaned_directories,
52                    )?;
53                    return Ok(DeletionResult {
54                        deleted_files,
55                        cleaned_directories,
56                    });
57                }
58            }
59        }
60
61        // For other files (like tasks or documents at root), just delete the file
62        if file_path.is_file() {
63            if let Err(e) = std::fs::remove_file(file_path) {
64                eprintln!(
65                    "Warning: Could not delete file {}: {}",
66                    file_path.display(),
67                    e
68                );
69            } else {
70                deleted_files.push(file_path.display().to_string());
71            }
72        }
73
74        Ok(DeletionResult {
75            deleted_files,
76            cleaned_directories,
77        })
78    }
79
80    /// Recursively remove a directory and all its contents
81    fn remove_directory_recursive(
82        dir_path: &Path,
83        deleted_files: &mut Vec<String>,
84        cleaned_directories: &mut Vec<String>,
85    ) -> Result<()> {
86        if !dir_path.exists() || !dir_path.is_dir() {
87            return Ok(());
88        }
89
90        // First, collect all files in this directory and subdirectories
91        let entries = std::fs::read_dir(dir_path).map_err(|e| {
92            crate::MetisError::FileSystem(format!(
93                "Failed to read directory {}: {}",
94                dir_path.display(),
95                e
96            ))
97        })?;
98
99        for entry in entries {
100            let entry = entry.map_err(|e| crate::MetisError::FileSystem(e.to_string()))?;
101            let path = entry.path();
102
103            if path.is_file() {
104                // Delete the file
105                if let Err(e) = std::fs::remove_file(&path) {
106                    eprintln!("Warning: Could not delete file {}: {}", path.display(), e);
107                } else {
108                    deleted_files.push(path.display().to_string());
109                }
110            } else if path.is_dir() {
111                // Recursively remove subdirectory
112                Self::remove_directory_recursive(&path, deleted_files, cleaned_directories)?;
113            }
114        }
115
116        // Now remove the empty directory
117        if let Err(e) = std::fs::remove_dir(dir_path) {
118            eprintln!(
119                "Warning: Could not remove directory {}: {}",
120                dir_path.display(),
121                e
122            );
123        } else {
124            cleaned_directories.push(dir_path.display().to_string());
125        }
126
127        Ok(())
128    }
129}
130
131/// Result of a document deletion operation
132#[derive(Debug)]
133pub struct DeletionResult {
134    pub deleted_files: Vec<String>,
135    pub cleaned_directories: Vec<String>,
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::application::services::document::{
142        creation::DocumentCreationConfig, DocumentCreationService,
143    };
144    use std::fs;
145    use std::path::PathBuf;
146    use tempfile::tempdir;
147
148    use crate::application::Application;
149    use crate::dal::Database;
150
151    async fn setup_test_workspace() -> (tempfile::TempDir, PathBuf) {
152        let temp_dir = tempdir().unwrap();
153        let workspace_dir = temp_dir.path().to_path_buf();
154
155        // Create .metis directory structure
156        let metis_dir = workspace_dir.join(".metis");
157        fs::create_dir_all(&metis_dir).unwrap();
158
159        // Initialize database
160        let db_path = workspace_dir.join("metis.db");
161        let db = Database::new(&db_path.to_string_lossy()).unwrap();
162        let app = Application::new(db);
163
164        // Create vision (required as root)
165        let creation_service = DocumentCreationService::new(&metis_dir);
166        let vision_config = DocumentCreationConfig {
167            title: "Test Vision".to_string(),
168            description: Some("Root vision for testing".to_string()),
169            parent_id: None,
170            tags: vec![],
171            phase: None,
172            complexity: None,
173            risk_level: None,
174        };
175        creation_service.create_vision(vision_config).await.unwrap();
176
177        // Sync to database
178        app.sync_directory(&metis_dir).await.unwrap();
179
180        (temp_dir, metis_dir)
181    }
182
183    #[tokio::test]
184    async fn test_delete_single_document_no_children() {
185        let (_temp_dir, workspace_dir) = setup_test_workspace().await;
186        let service = DeletionService::new();
187
188        // Create a test document (task - just a file)
189        let doc_path = workspace_dir.join("test.md");
190        fs::write(&doc_path, "# Test Document\nContent here").unwrap();
191
192        // Delete the document
193        let result = service
194            .delete_document_recursive(&doc_path.display().to_string())
195            .await
196            .unwrap();
197
198        // Verify results
199        assert_eq!(result.deleted_files.len(), 1);
200        assert!(!doc_path.exists());
201    }
202
203    #[tokio::test]
204    async fn test_delete_strategy_with_folder() {
205        let (_temp_dir, workspace_dir) = setup_test_workspace().await;
206
207        // Create strategy using creation service
208        let creation_service = DocumentCreationService::new(&workspace_dir);
209        let strategy_config = DocumentCreationConfig {
210            title: "Test Strategy".to_string(),
211            description: Some("Test strategy description".to_string()),
212            parent_id: None,
213            tags: vec![],
214            phase: None,
215            complexity: None,
216            risk_level: None,
217        };
218        let strategy_result = creation_service
219            .create_strategy(strategy_config)
220            .await
221            .unwrap();
222
223        // Create initiative under strategy
224        let initiative_config = DocumentCreationConfig {
225            title: "Test Initiative".to_string(),
226            description: Some("Test initiative description".to_string()),
227            parent_id: Some(strategy_result.document_id.clone()),
228            tags: vec![],
229            phase: None,
230            complexity: None,
231            risk_level: None,
232        };
233        let initiative_result = creation_service
234            .create_initiative(initiative_config, &strategy_result.document_id.to_string())
235            .await
236            .unwrap();
237
238        // Create task under initiative
239        let task_config = DocumentCreationConfig {
240            title: "Test Task".to_string(),
241            description: Some("Test task description".to_string()),
242            parent_id: Some(initiative_result.document_id.clone()),
243            tags: vec![],
244            phase: None,
245            complexity: None,
246            risk_level: None,
247        };
248        let task_result = creation_service
249            .create_task(
250                task_config,
251                &strategy_result.document_id.to_string(),
252                &initiative_result.document_id.to_string(),
253            )
254            .await
255            .unwrap();
256
257        // Verify files exist before deletion
258        assert!(strategy_result.file_path.exists());
259        assert!(initiative_result.file_path.exists());
260        assert!(task_result.file_path.exists());
261
262        // Delete the strategy
263        let deletion_service = DeletionService::new();
264        let result = deletion_service
265            .delete_document_recursive(&strategy_result.file_path.to_string_lossy())
266            .await
267            .unwrap();
268
269        // Verify entire strategy folder was deleted
270        let strategy_path = &strategy_result.file_path;
271        let strategy_folder = strategy_path.parent().unwrap();
272        assert!(!strategy_folder.exists());
273        assert!(!initiative_result.file_path.exists());
274        assert!(!task_result.file_path.exists());
275
276        // Should have deleted all files and directories
277        assert!(result.deleted_files.len() >= 3); // at least strategy.md + initiative.md + task.md
278        assert!(!result.cleaned_directories.is_empty()); // at least the strategy folder
279    }
280
281    #[tokio::test]
282    async fn test_delete_initiative_with_folder() {
283        let (_temp_dir, workspace_dir) = setup_test_workspace().await;
284
285        // Create strategy first (required parent)
286        let creation_service = DocumentCreationService::new(&workspace_dir);
287        let strategy_config = DocumentCreationConfig {
288            title: "Parent Strategy".to_string(),
289            description: Some("Parent strategy".to_string()),
290            parent_id: None,
291            tags: vec![],
292            phase: None,
293            complexity: None,
294            risk_level: None,
295        };
296        let strategy_result = creation_service
297            .create_strategy(strategy_config)
298            .await
299            .unwrap();
300
301        // Create initiative
302        let initiative_config = DocumentCreationConfig {
303            title: "Test Initiative".to_string(),
304            description: Some("Test initiative".to_string()),
305            parent_id: Some(strategy_result.document_id.clone()),
306            tags: vec![],
307            phase: None,
308            complexity: None,
309            risk_level: None,
310        };
311        let initiative_result = creation_service
312            .create_initiative(initiative_config, &strategy_result.document_id.to_string())
313            .await
314            .unwrap();
315
316        // Create tasks under initiative
317        let task1_config = DocumentCreationConfig {
318            title: "Task One".to_string(),
319            description: Some("First task".to_string()),
320            parent_id: Some(initiative_result.document_id.clone()),
321            tags: vec![],
322            phase: None,
323            complexity: None,
324            risk_level: None,
325        };
326        let task1_result = creation_service
327            .create_task(
328                task1_config,
329                &strategy_result.document_id.to_string(),
330                &initiative_result.document_id.to_string(),
331            )
332            .await
333            .unwrap();
334
335        let task2_config = DocumentCreationConfig {
336            title: "Task Two".to_string(),
337            description: Some("Second task".to_string()),
338            parent_id: Some(initiative_result.document_id.clone()),
339            tags: vec![],
340            phase: None,
341            complexity: None,
342            risk_level: None,
343        };
344        let task2_result = creation_service
345            .create_task(
346                task2_config,
347                &strategy_result.document_id.to_string(),
348                &initiative_result.document_id.to_string(),
349            )
350            .await
351            .unwrap();
352
353        // Delete the initiative
354        let deletion_service = DeletionService::new();
355        let result = deletion_service
356            .delete_document_recursive(&initiative_result.file_path.to_string_lossy())
357            .await
358            .unwrap();
359
360        // Verify initiative folder was deleted
361        let initiative_path = &initiative_result.file_path;
362        let initiative_folder = initiative_path.parent().unwrap();
363        assert!(!initiative_folder.exists());
364        assert!(!task1_result.file_path.exists());
365        assert!(!task2_result.file_path.exists());
366
367        // Verify strategy still exists
368        assert!(strategy_result.file_path.exists());
369
370        // Should have deleted all files in the initiative folder
371        assert!(result.deleted_files.len() >= 3); // at least initiative.md + task1.md + task2.md
372        assert!(!result.cleaned_directories.is_empty()); // at least the initiative folder
373    }
374
375    #[tokio::test]
376    async fn test_delete_nonexistent_document() {
377        let (_temp_dir, workspace_dir) = setup_test_workspace().await;
378        let service = DeletionService::new();
379
380        let nonexistent_path = workspace_dir.join("nonexistent.md");
381
382        // Should handle gracefully
383        let result = service
384            .delete_document_recursive(&nonexistent_path.display().to_string())
385            .await
386            .unwrap();
387
388        assert_eq!(result.deleted_files.len(), 0);
389        assert_eq!(result.cleaned_directories.len(), 0);
390    }
391
392    #[tokio::test]
393    async fn test_delete_task_file_only() {
394        let (_temp_dir, workspace_dir) = setup_test_workspace().await;
395
396        // Create full hierarchy up to task
397        let creation_service = DocumentCreationService::new(&workspace_dir);
398        let strategy_config = DocumentCreationConfig {
399            title: "Test Strategy".to_string(),
400            description: Some("Test strategy".to_string()),
401            parent_id: None,
402            tags: vec![],
403            phase: None,
404            complexity: None,
405            risk_level: None,
406        };
407        let strategy_result = creation_service
408            .create_strategy(strategy_config)
409            .await
410            .unwrap();
411
412        let initiative_config = DocumentCreationConfig {
413            title: "Test Initiative".to_string(),
414            description: Some("Test initiative".to_string()),
415            parent_id: Some(strategy_result.document_id.clone()),
416            tags: vec![],
417            phase: None,
418            complexity: None,
419            risk_level: None,
420        };
421        let initiative_result = creation_service
422            .create_initiative(initiative_config, &strategy_result.document_id.to_string())
423            .await
424            .unwrap();
425
426        let task_config = DocumentCreationConfig {
427            title: "Test Task".to_string(),
428            description: Some("Test task".to_string()),
429            parent_id: Some(initiative_result.document_id.clone()),
430            tags: vec![],
431            phase: None,
432            complexity: None,
433            risk_level: None,
434        };
435        let task_result = creation_service
436            .create_task(
437                task_config,
438                &strategy_result.document_id.to_string(),
439                &initiative_result.document_id.to_string(),
440            )
441            .await
442            .unwrap();
443
444        // Delete just the task
445        let deletion_service = DeletionService::new();
446        let result = deletion_service
447            .delete_document_recursive(&task_result.file_path.to_string_lossy())
448            .await
449            .unwrap();
450
451        // Task should be deleted
452        assert!(!task_result.file_path.exists());
453
454        // Parent documents should still exist
455        assert!(initiative_result.file_path.exists());
456        assert!(strategy_result.file_path.exists());
457
458        // Should only delete the task file
459        assert_eq!(result.deleted_files.len(), 1);
460        assert_eq!(result.cleaned_directories.len(), 0);
461    }
462
463    #[tokio::test]
464    async fn test_delete_document_no_folder() {
465        let (_temp_dir, workspace_dir) = setup_test_workspace().await;
466        let service = DeletionService::new();
467
468        // Create a document without an associated folder
469        let doc_path = workspace_dir.join("document.md");
470        fs::write(&doc_path, "# Document").unwrap();
471
472        // Delete the document
473        let result = service
474            .delete_document_recursive(&doc_path.display().to_string())
475            .await
476            .unwrap();
477
478        // Should only delete the file
479        assert!(!doc_path.exists());
480        assert_eq!(result.deleted_files.len(), 1);
481        assert_eq!(result.cleaned_directories.len(), 0);
482    }
483}