1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::{anyhow, Context, Result};
5use chrono::Utc;
6
7use crate::bean::Bean;
8use crate::discovery::find_bean_file;
9use crate::graph::detect_cycle;
10use crate::index::Index;
11
12pub fn cmd_dep_add(beans_dir: &Path, id: &str, depends_on_id: &str) -> Result<()> {
16 let bean_path = find_bean_file(beans_dir, id).map_err(|_| anyhow!("Bean {} not found", id))?;
18
19 find_bean_file(beans_dir, depends_on_id)
20 .map_err(|_| anyhow!("Bean {} not found", depends_on_id))?;
21
22 if id == depends_on_id {
24 return Err(anyhow!(
25 "Cannot add self-dependency: {} cannot depend on itself",
26 id
27 ));
28 }
29
30 let index = Index::load_or_rebuild(beans_dir)?;
32
33 if detect_cycle(&index, id, depends_on_id)? {
35 return Err(anyhow!(
36 "Dependency cycle detected: adding {} -> {} would create a cycle. Edge not added.",
37 id,
38 depends_on_id
39 ));
40 }
41
42 let mut bean =
44 Bean::from_file(&bean_path).with_context(|| format!("Failed to load bean: {}", id))?;
45
46 if bean.dependencies.contains(&depends_on_id.to_string()) {
48 return Err(anyhow!("Bean {} already depends on {}", id, depends_on_id));
49 }
50
51 bean.dependencies.push(depends_on_id.to_string());
52 bean.updated_at = Utc::now();
53
54 bean.to_file(&bean_path)
55 .with_context(|| format!("Failed to save bean: {}", id))?;
56
57 let index = Index::build(beans_dir).with_context(|| "Failed to rebuild index")?;
59 index
60 .save(beans_dir)
61 .with_context(|| "Failed to save index")?;
62
63 println!("{} now depends on {}", id, depends_on_id);
64
65 Ok(())
66}
67
68pub fn cmd_dep_remove(beans_dir: &Path, id: &str, depends_on_id: &str) -> Result<()> {
70 let bean_path = find_bean_file(beans_dir, id).map_err(|_| anyhow!("Bean {} not found", id))?;
71
72 let mut bean =
73 Bean::from_file(&bean_path).with_context(|| format!("Failed to load bean: {}", id))?;
74
75 let original_len = bean.dependencies.len();
76 bean.dependencies.retain(|d| d != depends_on_id);
77
78 if bean.dependencies.len() == original_len {
79 return Err(anyhow!("Bean {} does not depend on {}", id, depends_on_id));
80 }
81
82 bean.updated_at = Utc::now();
83 bean.to_file(&bean_path)
84 .with_context(|| format!("Failed to save bean: {}", id))?;
85
86 let index = Index::build(beans_dir).with_context(|| "Failed to rebuild index")?;
88 index
89 .save(beans_dir)
90 .with_context(|| "Failed to save index")?;
91
92 println!("{} no longer depends on {}", id, depends_on_id);
93
94 Ok(())
95}
96
97pub fn cmd_dep_list(beans_dir: &Path, id: &str) -> Result<()> {
99 let index = Index::load_or_rebuild(beans_dir)?;
100
101 let entry = index
102 .beans
103 .iter()
104 .find(|e| e.id == id)
105 .ok_or_else(|| anyhow!("Bean {} not found", id))?;
106
107 let id_map: HashMap<String, &crate::index::IndexEntry> =
109 index.beans.iter().map(|e| (e.id.clone(), e)).collect();
110
111 println!("Dependencies ({}):", entry.dependencies.len());
113 if entry.dependencies.is_empty() {
114 println!(" (none)");
115 } else {
116 for dep_id in &entry.dependencies {
117 if let Some(dep_entry) = id_map.get(dep_id) {
118 println!(" {} {}", dep_entry.id, dep_entry.title);
119 } else {
120 println!(" {} (not found)", dep_id);
121 }
122 }
123 }
124
125 let dependents: Vec<_> = index
127 .beans
128 .iter()
129 .filter(|e| e.dependencies.contains(&id.to_string()))
130 .collect();
131
132 println!("\nDependents ({}):", dependents.len());
133 if dependents.is_empty() {
134 println!(" (none)");
135 } else {
136 for dep in dependents {
137 println!(" {} {}", dep.id, dep.title);
138 }
139 }
140
141 Ok(())
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use crate::util::title_to_slug;
148 use std::fs;
149 use tempfile::TempDir;
150
151 fn setup_test_beans_dir() -> (TempDir, std::path::PathBuf) {
152 let dir = TempDir::new().unwrap();
153 let beans_dir = dir.path().join(".beans");
154 fs::create_dir(&beans_dir).unwrap();
155 (dir, beans_dir)
156 }
157
158 fn create_bean(beans_dir: &Path, bean: &Bean) {
160 let slug = title_to_slug(&bean.title);
161 let filename = format!("{}-{}.md", bean.id, slug);
162 bean.to_file(beans_dir.join(filename)).unwrap();
163 }
164
165 #[test]
166 fn test_dep_add_simple() {
167 let (_dir, beans_dir) = setup_test_beans_dir();
168 let bean1 = Bean::new("1", "Task 1");
169 let bean2 = Bean::new("2", "Task 2");
170 create_bean(&beans_dir, &bean1);
171 create_bean(&beans_dir, &bean2);
172
173 cmd_dep_add(&beans_dir, "1", "2").unwrap();
174
175 let updated = Bean::from_file(beans_dir.join("1-task-1.md")).unwrap();
176 assert_eq!(updated.dependencies, vec!["2".to_string()]);
177 }
178
179 #[test]
180 fn test_dep_add_self_dependency_rejected() {
181 let (_dir, beans_dir) = setup_test_beans_dir();
182 let bean1 = Bean::new("1", "Task 1");
183 create_bean(&beans_dir, &bean1);
184
185 let result = cmd_dep_add(&beans_dir, "1", "1");
186 assert!(result.is_err());
187 assert!(result.unwrap_err().to_string().contains("self-dependency"));
188 }
189
190 #[test]
191 fn test_dep_add_nonexistent_bean() {
192 let (_dir, beans_dir) = setup_test_beans_dir();
193 let bean1 = Bean::new("1", "Task 1");
194 create_bean(&beans_dir, &bean1);
195
196 let result = cmd_dep_add(&beans_dir, "1", "999");
197 assert!(result.is_err());
198 }
199
200 #[test]
201 fn test_dep_add_cycle_detection() {
202 let (_dir, beans_dir) = setup_test_beans_dir();
203 let mut bean1 = Bean::new("1", "Task 1");
204 let bean2 = Bean::new("2", "Task 2");
205 bean1.dependencies = vec!["2".to_string()];
206 create_bean(&beans_dir, &bean1);
207 create_bean(&beans_dir, &bean2);
208
209 Index::build(&beans_dir).unwrap().save(&beans_dir).unwrap();
211
212 let result = cmd_dep_add(&beans_dir, "2", "1");
214 assert!(result.is_err());
215 assert!(result.unwrap_err().to_string().contains("cycle"));
216 }
217
218 #[test]
219 fn test_dep_remove() {
220 let (_dir, beans_dir) = setup_test_beans_dir();
221 let mut bean1 = Bean::new("1", "Task 1");
222 let bean2 = Bean::new("2", "Task 2");
223 bean1.dependencies = vec!["2".to_string()];
224 create_bean(&beans_dir, &bean1);
225 create_bean(&beans_dir, &bean2);
226
227 cmd_dep_remove(&beans_dir, "1", "2").unwrap();
228
229 let updated = Bean::from_file(beans_dir.join("1-task-1.md")).unwrap();
230 assert_eq!(updated.dependencies, Vec::<String>::new());
231 }
232
233 #[test]
234 fn test_dep_remove_not_found() {
235 let (_dir, beans_dir) = setup_test_beans_dir();
236 let bean1 = Bean::new("1", "Task 1");
237 create_bean(&beans_dir, &bean1);
238
239 let result = cmd_dep_remove(&beans_dir, "1", "2");
240 assert!(result.is_err());
241 }
242
243 #[test]
244 fn test_dep_list_with_dependencies() {
245 let (_dir, beans_dir) = setup_test_beans_dir();
246 let mut bean1 = Bean::new("1", "Task 1");
247 let bean2 = Bean::new("2", "Task 2");
248 let mut bean3 = Bean::new("3", "Task 3");
249 bean1.dependencies = vec!["2".to_string()];
250 bean3.dependencies = vec!["1".to_string()];
251 create_bean(&beans_dir, &bean1);
252 create_bean(&beans_dir, &bean2);
253 create_bean(&beans_dir, &bean3);
254
255 let result = cmd_dep_list(&beans_dir, "1");
257 assert!(result.is_ok());
258 }
259
260 #[test]
261 fn test_dep_add_duplicate_rejected() {
262 let (_dir, beans_dir) = setup_test_beans_dir();
263 let mut bean1 = Bean::new("1", "Task 1");
264 let bean2 = Bean::new("2", "Task 2");
265 bean1.dependencies = vec!["2".to_string()];
266 create_bean(&beans_dir, &bean1);
267 create_bean(&beans_dir, &bean2);
268
269 let result = cmd_dep_add(&beans_dir, "1", "2");
271 assert!(result.is_err());
272 assert!(result.unwrap_err().to_string().contains("already depends"));
273 }
274}