Skip to main content

mana_core/ops/
dep.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::{anyhow, Context, Result};
5use chrono::Utc;
6
7use crate::discovery::find_unit_file;
8use crate::graph::detect_cycle;
9use crate::index::{Index, IndexEntry};
10use crate::unit::Unit;
11
12/// Result of adding a dependency.
13#[derive(Debug)]
14pub struct DepAddResult {
15    pub from_id: String,
16    pub to_id: String,
17}
18
19/// Result of removing a dependency.
20#[derive(Debug)]
21pub struct DepRemoveResult {
22    pub from_id: String,
23    pub to_id: String,
24}
25
26/// A dependency relationship for display.
27pub struct DepEntry {
28    pub id: String,
29    pub title: String,
30    pub found: bool,
31}
32
33/// Result of listing dependencies for a unit.
34pub struct DepListResult {
35    pub id: String,
36    pub dependencies: Vec<DepEntry>,
37    pub dependents: Vec<DepEntry>,
38}
39
40/// Add a dependency: `from_id` depends on `depends_on_id`.
41///
42/// Validates both units exist, checks for self-dependency,
43/// detects cycles, and persists the change.
44pub fn dep_add(mana_dir: &Path, from_id: &str, depends_on_id: &str) -> Result<DepAddResult> {
45    let unit_path =
46        find_unit_file(mana_dir, from_id).map_err(|_| anyhow!("Unit {} not found", from_id))?;
47
48    find_unit_file(mana_dir, depends_on_id)
49        .map_err(|_| anyhow!("Unit {} not found", depends_on_id))?;
50
51    if from_id == depends_on_id {
52        return Err(anyhow!(
53            "Cannot add self-dependency: {} cannot depend on itself",
54            from_id
55        ));
56    }
57
58    let index = Index::load_or_rebuild(mana_dir)?;
59
60    if detect_cycle(&index, from_id, depends_on_id)? {
61        return Err(anyhow!(
62            "Dependency cycle detected: adding {} -> {} would create a cycle. Edge not added.",
63            from_id,
64            depends_on_id
65        ));
66    }
67
68    let mut unit =
69        Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", from_id))?;
70
71    if unit.dependencies.contains(&depends_on_id.to_string()) {
72        return Err(anyhow!(
73            "Unit {} already depends on {}",
74            from_id,
75            depends_on_id
76        ));
77    }
78
79    unit.dependencies.push(depends_on_id.to_string());
80    unit.updated_at = Utc::now();
81
82    unit.to_file(&unit_path)
83        .with_context(|| format!("Failed to save unit: {}", from_id))?;
84
85    let index = Index::build(mana_dir).with_context(|| "Failed to rebuild index")?;
86    index
87        .save(mana_dir)
88        .with_context(|| "Failed to save index")?;
89
90    Ok(DepAddResult {
91        from_id: from_id.to_string(),
92        to_id: depends_on_id.to_string(),
93    })
94}
95
96/// Remove a dependency: `from_id` no longer depends on `depends_on_id`.
97pub fn dep_remove(mana_dir: &Path, from_id: &str, depends_on_id: &str) -> Result<DepRemoveResult> {
98    let unit_path =
99        find_unit_file(mana_dir, from_id).map_err(|_| anyhow!("Unit {} not found", from_id))?;
100
101    let mut unit =
102        Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", from_id))?;
103
104    let original_len = unit.dependencies.len();
105    unit.dependencies.retain(|d| d != depends_on_id);
106
107    if unit.dependencies.len() == original_len {
108        return Err(anyhow!(
109            "Unit {} does not depend on {}",
110            from_id,
111            depends_on_id
112        ));
113    }
114
115    unit.updated_at = Utc::now();
116    unit.to_file(&unit_path)
117        .with_context(|| format!("Failed to save unit: {}", from_id))?;
118
119    let index = Index::build(mana_dir).with_context(|| "Failed to rebuild index")?;
120    index
121        .save(mana_dir)
122        .with_context(|| "Failed to save index")?;
123
124    Ok(DepRemoveResult {
125        from_id: from_id.to_string(),
126        to_id: depends_on_id.to_string(),
127    })
128}
129
130/// List dependencies and dependents for a unit.
131pub fn dep_list(mana_dir: &Path, id: &str) -> Result<DepListResult> {
132    let index = Index::load_or_rebuild(mana_dir)?;
133
134    let entry = index
135        .units
136        .iter()
137        .find(|e| e.id == id)
138        .ok_or_else(|| anyhow!("Unit {} not found", id))?;
139
140    let id_map: HashMap<String, &IndexEntry> =
141        index.units.iter().map(|e| (e.id.clone(), e)).collect();
142
143    let dependencies: Vec<DepEntry> = entry
144        .dependencies
145        .iter()
146        .map(|dep_id| {
147            if let Some(dep_entry) = id_map.get(dep_id) {
148                DepEntry {
149                    id: dep_entry.id.clone(),
150                    title: dep_entry.title.clone(),
151                    found: true,
152                }
153            } else {
154                DepEntry {
155                    id: dep_id.clone(),
156                    title: String::new(),
157                    found: false,
158                }
159            }
160        })
161        .collect();
162
163    let dependents: Vec<DepEntry> = index
164        .units
165        .iter()
166        .filter(|e| e.dependencies.contains(&id.to_string()))
167        .map(|e| DepEntry {
168            id: e.id.clone(),
169            title: e.title.clone(),
170            found: true,
171        })
172        .collect();
173
174    Ok(DepListResult {
175        id: id.to_string(),
176        dependencies,
177        dependents,
178    })
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::util::title_to_slug;
185    use std::fs;
186    use tempfile::TempDir;
187
188    fn setup_test_mana_dir() -> (TempDir, std::path::PathBuf) {
189        let dir = TempDir::new().unwrap();
190        let mana_dir = dir.path().join(".mana");
191        fs::create_dir(&mana_dir).unwrap();
192        (dir, mana_dir)
193    }
194
195    fn create_unit(mana_dir: &Path, unit: &Unit) {
196        let slug = title_to_slug(&unit.title);
197        let filename = format!("{}-{}.md", unit.id, slug);
198        unit.to_file(mana_dir.join(filename)).unwrap();
199    }
200
201    #[test]
202    fn test_dep_add_simple() {
203        let (_dir, mana_dir) = setup_test_mana_dir();
204        let unit1 = Unit::new("1", "Task 1");
205        let unit2 = Unit::new("2", "Task 2");
206        create_unit(&mana_dir, &unit1);
207        create_unit(&mana_dir, &unit2);
208
209        let result = dep_add(&mana_dir, "1", "2").unwrap();
210        assert_eq!(result.from_id, "1");
211        assert_eq!(result.to_id, "2");
212
213        let updated = Unit::from_file(mana_dir.join("1-task-1.md")).unwrap();
214        assert_eq!(updated.dependencies, vec!["2".to_string()]);
215    }
216
217    #[test]
218    fn test_dep_add_self_dependency_rejected() {
219        let (_dir, mana_dir) = setup_test_mana_dir();
220        let unit1 = Unit::new("1", "Task 1");
221        create_unit(&mana_dir, &unit1);
222
223        let result = dep_add(&mana_dir, "1", "1");
224        assert!(result.is_err());
225        assert!(result.unwrap_err().to_string().contains("self-dependency"));
226    }
227
228    #[test]
229    fn test_dep_add_nonexistent_unit() {
230        let (_dir, mana_dir) = setup_test_mana_dir();
231        let unit1 = Unit::new("1", "Task 1");
232        create_unit(&mana_dir, &unit1);
233
234        let result = dep_add(&mana_dir, "1", "999");
235        assert!(result.is_err());
236    }
237
238    #[test]
239    fn test_dep_add_cycle_detection() {
240        let (_dir, mana_dir) = setup_test_mana_dir();
241        let mut unit1 = Unit::new("1", "Task 1");
242        let unit2 = Unit::new("2", "Task 2");
243        unit1.dependencies = vec!["2".to_string()];
244        create_unit(&mana_dir, &unit1);
245        create_unit(&mana_dir, &unit2);
246
247        Index::build(&mana_dir).unwrap().save(&mana_dir).unwrap();
248
249        let result = dep_add(&mana_dir, "2", "1");
250        assert!(result.is_err());
251        assert!(result.unwrap_err().to_string().contains("cycle"));
252    }
253
254    #[test]
255    fn test_dep_remove() {
256        let (_dir, mana_dir) = setup_test_mana_dir();
257        let mut unit1 = Unit::new("1", "Task 1");
258        let unit2 = Unit::new("2", "Task 2");
259        unit1.dependencies = vec!["2".to_string()];
260        create_unit(&mana_dir, &unit1);
261        create_unit(&mana_dir, &unit2);
262
263        let result = dep_remove(&mana_dir, "1", "2").unwrap();
264        assert_eq!(result.from_id, "1");
265        assert_eq!(result.to_id, "2");
266
267        let updated = Unit::from_file(mana_dir.join("1-task-1.md")).unwrap();
268        assert_eq!(updated.dependencies, Vec::<String>::new());
269    }
270
271    #[test]
272    fn test_dep_remove_not_found() {
273        let (_dir, mana_dir) = setup_test_mana_dir();
274        let unit1 = Unit::new("1", "Task 1");
275        create_unit(&mana_dir, &unit1);
276
277        let result = dep_remove(&mana_dir, "1", "2");
278        assert!(result.is_err());
279    }
280
281    #[test]
282    fn test_dep_list_with_dependencies() {
283        let (_dir, mana_dir) = setup_test_mana_dir();
284        let mut unit1 = Unit::new("1", "Task 1");
285        let unit2 = Unit::new("2", "Task 2");
286        let mut unit3 = Unit::new("3", "Task 3");
287        unit1.dependencies = vec!["2".to_string()];
288        unit3.dependencies = vec!["1".to_string()];
289        create_unit(&mana_dir, &unit1);
290        create_unit(&mana_dir, &unit2);
291        create_unit(&mana_dir, &unit3);
292
293        let result = dep_list(&mana_dir, "1").unwrap();
294        assert_eq!(result.dependencies.len(), 1);
295        assert_eq!(result.dependencies[0].id, "2");
296        assert_eq!(result.dependents.len(), 1);
297        assert_eq!(result.dependents[0].id, "3");
298    }
299
300    #[test]
301    fn test_dep_add_duplicate_rejected() {
302        let (_dir, mana_dir) = setup_test_mana_dir();
303        let mut unit1 = Unit::new("1", "Task 1");
304        let unit2 = Unit::new("2", "Task 2");
305        unit1.dependencies = vec!["2".to_string()];
306        create_unit(&mana_dir, &unit1);
307        create_unit(&mana_dir, &unit2);
308
309        let result = dep_add(&mana_dir, "1", "2");
310        assert!(result.is_err());
311        assert!(result.unwrap_err().to_string().contains("already depends"));
312    }
313}