Skip to main content

bn/commands/
dep.rs

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
12/// Add a dependency: `bn dep add <id> <depends-on-id>`
13/// Sets id.dependencies to include depends-on-id.
14/// Checks for cycles before adding.
15pub fn cmd_dep_add(beans_dir: &Path, id: &str, depends_on_id: &str) -> Result<()> {
16    // Verify both beans exist (supports both .md and legacy .yaml formats)
17    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    // Check for self-dependency
23    if id == depends_on_id {
24        return Err(anyhow!(
25            "Cannot add self-dependency: {} cannot depend on itself",
26            id
27        ));
28    }
29
30    // Load index once for cycle detection
31    let index = Index::load_or_rebuild(beans_dir)?;
32
33    // Check for cycles
34    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    // Load the bean and add dependency
43    let mut bean =
44        Bean::from_file(&bean_path).with_context(|| format!("Failed to load bean: {}", id))?;
45
46    // Check if already dependent
47    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    // Rebuild index
58    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
68/// Remove a dependency: `bn dep remove <id> <depends-on-id>`
69pub 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    // Rebuild index
87    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
97/// List dependencies and dependents: `bn dep list <id>`
98pub 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    // Create id -> entry map
108    let id_map: HashMap<String, &crate::index::IndexEntry> =
109        index.beans.iter().map(|e| (e.id.clone(), e)).collect();
110
111    // Print dependencies
112    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    // Find dependents (reverse lookup)
126    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    /// Helper to create a bean file with the new {id}-{slug}.md format
159    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        // Rebuild index so it's fresh
210        Index::build(&beans_dir).unwrap().save(&beans_dir).unwrap();
211
212        // Try to add 2 -> 1, which creates a cycle
213        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        // This should succeed — just testing that it runs
256        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        // Try to add the same dependency again
270        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}