1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use chrono::Utc;
7
8use crate::discovery::find_unit_file;
9use crate::index::Index;
10use crate::unit::Unit;
11
12pub struct AdoptResult {
14 pub id_map: HashMap<String, String>,
16}
17
18fn next_child_number(mana_dir: &Path, parent_id: &str) -> Result<u32> {
20 let mut max_child: u32 = 0;
21
22 let dir_entries = fs::read_dir(mana_dir)
23 .with_context(|| format!("Failed to read directory: {}", mana_dir.display()))?;
24
25 for entry in dir_entries {
26 let entry = entry?;
27 let path = entry.path();
28 let filename = path
29 .file_name()
30 .and_then(|n| n.to_str())
31 .unwrap_or_default();
32
33 if let Some(name_without_ext) = filename.strip_suffix(".md") {
34 if let Some(name_without_parent) = name_without_ext.strip_prefix(parent_id) {
35 if let Some(after_dot) = name_without_parent.strip_prefix('.') {
36 let num_part = after_dot.split('-').next().unwrap_or_default();
37 if let Ok(child_num) = num_part.parse::<u32>() {
38 if child_num > max_child {
39 max_child = child_num;
40 }
41 }
42 }
43 }
44 }
45
46 if let Some(name_without_ext) = filename.strip_suffix(".yaml") {
47 if let Some(name_without_parent) = name_without_ext.strip_prefix(parent_id) {
48 if let Some(after_dot) = name_without_parent.strip_prefix('.') {
49 if let Ok(child_num) = after_dot.parse::<u32>() {
50 if child_num > max_child {
51 max_child = child_num;
52 }
53 }
54 }
55 }
56 }
57 }
58
59 Ok(max_child + 1)
60}
61
62fn update_all_dependencies(mana_dir: &Path, id_map: &HashMap<String, String>) -> Result<()> {
64 let dir_entries = fs::read_dir(mana_dir)
65 .with_context(|| format!("Failed to read directory: {}", mana_dir.display()))?;
66
67 for entry in dir_entries {
68 let entry = entry?;
69 let path = entry.path();
70 let filename = path
71 .file_name()
72 .and_then(|n| n.to_str())
73 .unwrap_or_default();
74
75 let is_unit_file = (filename.ends_with(".md") && filename.contains('-'))
76 || (filename.ends_with(".yaml")
77 && filename != "config.yaml"
78 && filename != "index.yaml"
79 && filename != "unit.yaml");
80
81 if !is_unit_file {
82 continue;
83 }
84
85 let mut unit = match Unit::from_file(&path) {
86 Ok(b) => b,
87 Err(_) => continue,
88 };
89
90 let mut modified = false;
91 let mut new_deps = Vec::new();
92
93 for dep in &unit.dependencies {
94 if let Some(new_id) = id_map.get(dep) {
95 new_deps.push(new_id.clone());
96 modified = true;
97 } else {
98 new_deps.push(dep.clone());
99 }
100 }
101
102 if let Some(ref parent) = unit.parent {
103 if let Some(new_parent) = id_map.get(parent) {
104 unit.parent = Some(new_parent.clone());
105 modified = true;
106 }
107 }
108
109 if modified {
110 unit.dependencies = new_deps;
111 unit.updated_at = Utc::now();
112 unit.to_file(&path)
113 .with_context(|| format!("Failed to update unit {}", path.display()))?;
114 }
115 }
116
117 Ok(())
118}
119
120pub fn adopt(mana_dir: &Path, parent_id: &str, child_ids: &[String]) -> Result<AdoptResult> {
127 let parent_path = find_unit_file(mana_dir, parent_id)
128 .with_context(|| format!("Parent unit '{}' not found", parent_id))?;
129 let _parent_unit = Unit::from_file(&parent_path)
130 .with_context(|| format!("Failed to load parent unit '{}'", parent_id))?;
131
132 let mut id_map: HashMap<String, String> = HashMap::new();
133 for (next_num, old_id) in (next_child_number(mana_dir, parent_id)?..).zip(child_ids.iter()) {
134 let old_path = find_unit_file(mana_dir, old_id)
135 .with_context(|| format!("Child unit '{}' not found", old_id))?;
136 let mut unit = Unit::from_file(&old_path)
137 .with_context(|| format!("Failed to load child unit '{}'", old_id))?;
138
139 let new_id = format!("{}.{}", parent_id, next_num);
140
141 unit.id = new_id.clone();
142 unit.parent = Some(parent_id.to_string());
143 unit.updated_at = Utc::now();
144
145 let slug = unit.slug.clone().unwrap_or_else(|| "unnamed".to_string());
146 let new_filename = format!("{}-{}.md", new_id, slug);
147 let new_path = mana_dir.join(&new_filename);
148
149 unit.to_file(&new_path)
150 .with_context(|| format!("Failed to write unit to {}", new_path.display()))?;
151
152 if old_path != new_path {
153 fs::remove_file(&old_path).with_context(|| {
154 format!("Failed to remove old unit file {}", old_path.display())
155 })?;
156 }
157
158 id_map.insert(old_id.clone(), new_id);
159 }
160
161 if !id_map.is_empty() {
162 update_all_dependencies(mana_dir, &id_map)?;
163 }
164
165 let index = Index::build(mana_dir)?;
166 index.save(mana_dir)?;
167
168 Ok(AdoptResult { id_map })
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::config::Config;
175 use tempfile::TempDir;
176
177 fn setup_mana_dir() -> (TempDir, std::path::PathBuf) {
178 let dir = TempDir::new().unwrap();
179 let mana_dir = dir.path().join(".mana");
180 fs::create_dir(&mana_dir).unwrap();
181
182 Config {
183 project: "test".to_string(),
184 next_id: 10,
185 auto_close_parent: true,
186 run: None,
187 plan: None,
188 max_loops: 10,
189 max_concurrent: 4,
190 poll_interval: 30,
191 extends: vec![],
192 rules_file: None,
193 file_locking: false,
194 worktree: false,
195 on_close: None,
196 on_fail: None,
197 verify_timeout: None,
198 review: None,
199 user: None,
200 user_email: None,
201 auto_commit: false,
202 commit_template: None,
203 research: None,
204 run_model: None,
205 plan_model: None,
206 review_model: None,
207 research_model: None,
208 batch_verify: false,
209 memory_reserve_mb: 0,
210 notify: None,
211 }
212 .save(&mana_dir)
213 .unwrap();
214
215 (dir, mana_dir)
216 }
217
218 #[test]
219 fn adopt_single_unit() {
220 let (_dir, mana_dir) = setup_mana_dir();
221
222 let mut parent = Unit::new("1", "Parent task");
223 parent.slug = Some("parent-task".to_string());
224 parent.acceptance = Some("Children complete".to_string());
225 parent.to_file(mana_dir.join("1-parent-task.md")).unwrap();
226
227 let mut child = Unit::new("2", "Child task");
228 child.slug = Some("child-task".to_string());
229 child.verify = Some("cargo test unit::check".to_string());
230 child.to_file(mana_dir.join("2-child-task.md")).unwrap();
231
232 let result = adopt(&mana_dir, "1", &["2".to_string()]).unwrap();
233
234 assert_eq!(result.id_map.get("2"), Some(&"1.1".to_string()));
235 assert!(!mana_dir.join("2-child-task.md").exists());
236 assert!(mana_dir.join("1.1-child-task.md").exists());
237 }
238
239 #[test]
240 fn adopt_fails_for_missing_parent() {
241 let (_dir, mana_dir) = setup_mana_dir();
242
243 let mut child = Unit::new("2", "Child");
244 child.slug = Some("child".to_string());
245 child.to_file(mana_dir.join("2-child.md")).unwrap();
246
247 let result = adopt(&mana_dir, "99", &["2".to_string()]);
248 assert!(result.is_err());
249 }
250
251 #[test]
252 fn adopt_updates_dependencies() {
253 let (_dir, mana_dir) = setup_mana_dir();
254
255 let mut parent = Unit::new("1", "Parent");
256 parent.slug = Some("parent".to_string());
257 parent.acceptance = Some("Done".to_string());
258 parent.to_file(mana_dir.join("1-parent.md")).unwrap();
259
260 let mut to_adopt = Unit::new("2", "To adopt");
261 to_adopt.slug = Some("to-adopt".to_string());
262 to_adopt.verify = Some("true".to_string());
263 to_adopt.to_file(mana_dir.join("2-to-adopt.md")).unwrap();
264
265 let mut dependent = Unit::new("3", "Dependent");
266 dependent.slug = Some("dependent".to_string());
267 dependent.verify = Some("true".to_string());
268 dependent.dependencies = vec!["2".to_string()];
269 dependent.to_file(mana_dir.join("3-dependent.md")).unwrap();
270
271 adopt(&mana_dir, "1", &["2".to_string()]).unwrap();
272
273 let dependent_updated = Unit::from_file(mana_dir.join("3-dependent.md")).unwrap();
274 assert_eq!(dependent_updated.dependencies, vec!["1.1".to_string()]);
275 }
276}