Skip to main content

mana_core/ops/
adopt.rs

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
12/// Result of an adopt operation.
13pub struct AdoptResult {
14    /// Map of old_id -> new_id for adopted units.
15    pub id_map: HashMap<String, String>,
16}
17
18/// Find the next available child number for a parent.
19fn 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
62/// Update dependency references in all units based on the ID mapping.
63fn 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
120/// Adopt existing units as children of a parent unit.
121///
122/// For each child ID, assigns a new sequential child ID under the parent,
123/// renames the file, updates all dependency references, and rebuilds the index.
124///
125/// Returns a map of old_id -> new_id.
126pub 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}