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