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#[derive(Debug)]
14pub struct DepAddResult {
15 pub from_id: String,
16 pub to_id: String,
17}
18
19#[derive(Debug)]
21pub struct DepRemoveResult {
22 pub from_id: String,
23 pub to_id: String,
24}
25
26pub struct DepEntry {
28 pub id: String,
29 pub title: String,
30 pub found: bool,
31}
32
33pub struct DepListResult {
35 pub id: String,
36 pub dependencies: Vec<DepEntry>,
37 pub dependents: Vec<DepEntry>,
38}
39
40pub 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
96pub 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
130pub 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}