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#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct DepAddResult {
17 pub from_id: String,
18 pub to_id: String,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct DepRemoveResult {
24 pub from_id: String,
25 pub to_id: String,
26}
27
28pub struct DepEntry {
30 pub id: String,
31 pub title: String,
32 pub found: bool,
33}
34
35pub struct DepListResult {
37 pub id: String,
38 pub dependencies: Vec<DepEntry>,
39 pub dependents: Vec<DepEntry>,
40}
41
42pub 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
98pub 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
132pub 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}