Skip to main content

bn/commands/
delete.rs

1use std::fs;
2use std::path::Path;
3
4use anyhow::Context;
5use anyhow::Result;
6
7use crate::bean::Bean;
8use crate::discovery::find_bean_file;
9use crate::index::Index;
10
11/// Delete a bean and clean up all references to it in other beans' dependencies.
12///
13/// 1. Load the bean to get its title (for printing)
14/// 2. Delete the bean file
15/// 3. Scan all remaining beans and remove deleted_id from their dependencies
16/// 4. Rebuild the index
17pub fn cmd_delete(beans_dir: &Path, id: &str) -> Result<()> {
18    let bean_path =
19        find_bean_file(beans_dir, id).with_context(|| format!("Bean not found: {}", id))?;
20
21    // Load the bean to get title before deleting
22    let bean =
23        Bean::from_file(&bean_path).with_context(|| format!("Failed to load bean: {}", id))?;
24    let title = bean.title.clone();
25
26    // Delete the bean file
27    fs::remove_file(&bean_path).with_context(|| format!("Failed to delete bean file: {}", id))?;
28
29    // Clean up dependency references
30    cleanup_dep_references(beans_dir, id)
31        .with_context(|| format!("Failed to clean up dependency references for: {}", id))?;
32
33    // Rebuild index
34    let index = Index::build(beans_dir).with_context(|| "Failed to rebuild index")?;
35    index
36        .save(beans_dir)
37        .with_context(|| "Failed to save index")?;
38
39    println!("Deleted bean {}: {}", id, title);
40    Ok(())
41}
42
43/// Helper: scan all beans and remove deleted_id from their dependencies lists.
44fn cleanup_dep_references(beans_dir: &Path, deleted_id: &str) -> Result<()> {
45    let dir_entries = fs::read_dir(beans_dir)
46        .with_context(|| format!("Failed to read directory: {}", beans_dir.display()))?;
47
48    for entry in dir_entries {
49        let entry = entry?;
50        let path = entry.path();
51
52        let filename = path
53            .file_name()
54            .and_then(|n| n.to_str())
55            .unwrap_or_default();
56
57        // Skip excluded files
58        if filename == "index.yaml" || filename == "config.yaml" || filename == "bean.yaml" {
59            continue;
60        }
61
62        // Check for both .md and .yaml bean files
63        let ext = path.extension().and_then(|e| e.to_str());
64        let is_bean_file = match ext {
65            Some("md") => filename.contains('-'), // New format: {id}-{slug}.md
66            Some("yaml") => true,                 // Legacy format: {id}.yaml
67            _ => false,
68        };
69
70        if !is_bean_file {
71            continue;
72        }
73
74        // Load the bean
75        if let Ok(mut bean) = Bean::from_file(&path) {
76            // Remove deleted_id from dependencies if present
77            let original_len = bean.dependencies.len();
78            bean.dependencies.retain(|dep| dep != deleted_id);
79
80            // Only write if we actually removed something
81            if bean.dependencies.len() < original_len {
82                bean.to_file(&path)?;
83            }
84        }
85    }
86
87    Ok(())
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::util::title_to_slug;
94    use tempfile::TempDir;
95
96    fn setup_test_beans_dir() -> (TempDir, std::path::PathBuf) {
97        let dir = TempDir::new().unwrap();
98        let beans_dir = dir.path().join(".beans");
99        fs::create_dir(&beans_dir).unwrap();
100        (dir, beans_dir)
101    }
102
103    #[test]
104    fn test_delete_bean() {
105        let (_dir, beans_dir) = setup_test_beans_dir();
106        let bean = Bean::new("1", "Task to delete");
107        let slug = title_to_slug(&bean.title);
108        let bean_path = beans_dir.join(format!("1-{}.md", slug));
109        bean.to_file(&bean_path).unwrap();
110
111        assert!(bean_path.exists());
112
113        cmd_delete(&beans_dir, "1").unwrap();
114
115        assert!(!bean_path.exists());
116    }
117
118    #[test]
119    fn test_delete_nonexistent_bean() {
120        let (_dir, beans_dir) = setup_test_beans_dir();
121        let result = cmd_delete(&beans_dir, "99");
122        assert!(result.is_err());
123    }
124
125    #[test]
126    fn test_delete_cleans_dependencies() {
127        let (_dir, beans_dir) = setup_test_beans_dir();
128
129        // Create beans with dependencies
130        let bean1 = Bean::new("1", "Task 1");
131        let mut bean2 = Bean::new("2", "Task 2");
132        let mut bean3 = Bean::new("3", "Task 3");
133
134        bean2.dependencies = vec!["1".to_string()];
135        bean3.dependencies = vec!["1".to_string(), "2".to_string()];
136
137        let slug1 = title_to_slug(&bean1.title);
138        let slug2 = title_to_slug(&bean2.title);
139        let slug3 = title_to_slug(&bean3.title);
140
141        bean1
142            .to_file(beans_dir.join(format!("1-{}.md", slug1)))
143            .unwrap();
144        bean2
145            .to_file(beans_dir.join(format!("2-{}.md", slug2)))
146            .unwrap();
147        bean3
148            .to_file(beans_dir.join(format!("3-{}.md", slug3)))
149            .unwrap();
150
151        // Delete bean 1
152        cmd_delete(&beans_dir, "1").unwrap();
153
154        // Verify bean 2 no longer depends on 1
155        let bean2_updated =
156            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "2").unwrap()).unwrap();
157        assert!(!bean2_updated.dependencies.contains(&"1".to_string()));
158
159        // Verify bean 3 no longer depends on 1, but still depends on 2
160        let bean3_updated =
161            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "3").unwrap()).unwrap();
162        assert!(!bean3_updated.dependencies.contains(&"1".to_string()));
163        assert!(bean3_updated.dependencies.contains(&"2".to_string()));
164    }
165
166    #[test]
167    fn test_delete_rebuilds_index() {
168        let (_dir, beans_dir) = setup_test_beans_dir();
169        let bean1 = Bean::new("1", "Task 1");
170        let bean2 = Bean::new("2", "Task 2");
171        let slug1 = title_to_slug(&bean1.title);
172        let slug2 = title_to_slug(&bean2.title);
173        bean1
174            .to_file(beans_dir.join(format!("1-{}.md", slug1)))
175            .unwrap();
176        bean2
177            .to_file(beans_dir.join(format!("2-{}.md", slug2)))
178            .unwrap();
179
180        cmd_delete(&beans_dir, "1").unwrap();
181
182        let index = Index::load(&beans_dir).unwrap();
183        assert_eq!(index.beans.len(), 1);
184        assert_eq!(index.beans[0].id, "2");
185    }
186
187    #[test]
188    fn test_cleanup_does_not_modify_unrelated_beans() {
189        let (_dir, beans_dir) = setup_test_beans_dir();
190
191        // Create beans where only some depend on 1
192        let bean1 = Bean::new("1", "Task 1");
193        let mut bean2 = Bean::new("2", "Task 2");
194        let bean3 = Bean::new("3", "Task 3"); // No dependencies
195
196        bean2.dependencies = vec!["1".to_string()];
197
198        let slug1 = title_to_slug(&bean1.title);
199        let slug2 = title_to_slug(&bean2.title);
200        let slug3 = title_to_slug(&bean3.title);
201
202        bean1
203            .to_file(beans_dir.join(format!("1-{}.md", slug1)))
204            .unwrap();
205        bean2
206            .to_file(beans_dir.join(format!("2-{}.md", slug2)))
207            .unwrap();
208        bean3
209            .to_file(beans_dir.join(format!("3-{}.md", slug3)))
210            .unwrap();
211
212        cmd_delete(&beans_dir, "1").unwrap();
213
214        let bean3_check =
215            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "3").unwrap()).unwrap();
216        assert!(bean3_check.dependencies.is_empty());
217    }
218
219    #[test]
220    fn test_delete_with_complex_dependency_graph() {
221        let (_dir, beans_dir) = setup_test_beans_dir();
222
223        // Create a diamond dependency: 4 <- [2, 3], 2 <- 1, 3 <- 1
224        let bean1 = Bean::new("1", "Root");
225        let mut bean2 = Bean::new("2", "Middle left");
226        let mut bean3 = Bean::new("3", "Middle right");
227        let mut bean4 = Bean::new("4", "Bottom");
228
229        bean2.dependencies = vec!["1".to_string()];
230        bean3.dependencies = vec!["1".to_string()];
231        bean4.dependencies = vec!["2".to_string(), "3".to_string()];
232
233        let slug1 = title_to_slug(&bean1.title);
234        let slug2 = title_to_slug(&bean2.title);
235        let slug3 = title_to_slug(&bean3.title);
236        let slug4 = title_to_slug(&bean4.title);
237
238        bean1
239            .to_file(beans_dir.join(format!("1-{}.md", slug1)))
240            .unwrap();
241        bean2
242            .to_file(beans_dir.join(format!("2-{}.md", slug2)))
243            .unwrap();
244        bean3
245            .to_file(beans_dir.join(format!("3-{}.md", slug3)))
246            .unwrap();
247        bean4
248            .to_file(beans_dir.join(format!("4-{}.md", slug4)))
249            .unwrap();
250
251        // Delete node 1
252        cmd_delete(&beans_dir, "1").unwrap();
253
254        // Verify cleanup
255        let bean2_updated =
256            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "2").unwrap()).unwrap();
257        let bean3_updated =
258            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "3").unwrap()).unwrap();
259        let bean4_updated =
260            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "4").unwrap()).unwrap();
261
262        assert!(!bean2_updated.dependencies.contains(&"1".to_string()));
263        assert!(!bean3_updated.dependencies.contains(&"1".to_string()));
264        assert!(!bean4_updated.dependencies.contains(&"1".to_string()));
265        assert!(bean4_updated.dependencies.contains(&"2".to_string()));
266        assert!(bean4_updated.dependencies.contains(&"3".to_string()));
267    }
268
269    #[test]
270    fn test_delete_ignores_excluded_files() {
271        let (_dir, beans_dir) = setup_test_beans_dir();
272
273        let bean1 = Bean::new("1", "Task 1");
274        let slug1 = title_to_slug(&bean1.title);
275        bean1
276            .to_file(beans_dir.join(format!("1-{}.md", slug1)))
277            .unwrap();
278
279        // Create config.yaml with a fake reference to "1"
280        fs::write(
281            beans_dir.join("config.yaml"),
282            "next_id: 2\nproject_name: test\n",
283        )
284        .unwrap();
285
286        // This should not fail even though config.yaml exists
287        cmd_delete(&beans_dir, "1").unwrap();
288        assert!(!beans_dir.join(format!("1-{}.md", slug1)).exists());
289    }
290}