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
11pub 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 let bean =
23 Bean::from_file(&bean_path).with_context(|| format!("Failed to load bean: {}", id))?;
24 let title = bean.title.clone();
25
26 fs::remove_file(&bean_path).with_context(|| format!("Failed to delete bean file: {}", id))?;
28
29 cleanup_dep_references(beans_dir, id)
31 .with_context(|| format!("Failed to clean up dependency references for: {}", id))?;
32
33 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
43fn 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 if filename == "index.yaml" || filename == "config.yaml" || filename == "bean.yaml" {
59 continue;
60 }
61
62 let ext = path.extension().and_then(|e| e.to_str());
64 let is_bean_file = match ext {
65 Some("md") => filename.contains('-'), Some("yaml") => true, _ => false,
68 };
69
70 if !is_bean_file {
71 continue;
72 }
73
74 if let Ok(mut bean) = Bean::from_file(&path) {
76 let original_len = bean.dependencies.len();
78 bean.dependencies.retain(|dep| dep != deleted_id);
79
80 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 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 cmd_delete(&beans_dir, "1").unwrap();
153
154 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 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 let bean1 = Bean::new("1", "Task 1");
193 let mut bean2 = Bean::new("2", "Task 2");
194 let bean3 = Bean::new("3", "Task 3"); 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 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 cmd_delete(&beans_dir, "1").unwrap();
253
254 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 fs::write(
281 beans_dir.join("config.yaml"),
282 "next_id: 2\nproject_name: test\n",
283 )
284 .unwrap();
285
286 cmd_delete(&beans_dir, "1").unwrap();
288 assert!(!beans_dir.join(format!("1-{}.md", slug1)).exists());
289 }
290}