Skip to main content

mana_core/ops/
move_units.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{bail, Context, Result};
6use chrono::Utc;
7
8use crate::config::Config;
9use crate::discovery::find_unit_file;
10use crate::index::Index;
11use crate::unit::Unit;
12
13/// Result of a move operation.
14pub struct MoveResult {
15    /// Map of old_id -> new_id.
16    pub id_map: HashMap<String, String>,
17}
18
19/// Resolve a path to a `.mana/` directory.
20///
21/// Accepts either a path ending in `.mana/` or a project directory containing `.mana/`.
22pub fn resolve_mana_dir(path: &Path) -> Result<PathBuf> {
23    if path.is_dir() && path.file_name().is_some_and(|n| n == ".mana") {
24        return Ok(path.to_path_buf());
25    }
26
27    let candidate = path.join(".mana");
28    if candidate.is_dir() {
29        return Ok(candidate);
30    }
31
32    bail!(
33        "No .mana/ directory found at '{}'\n\
34         Pass the path to a .mana/ directory or the project directory containing it.",
35        path.display()
36    );
37}
38
39/// Move units between two `.mana/` directories.
40///
41/// For each unit ID, loads from source, assigns new sequential ID in destination,
42/// writes to destination, removes from source, and rebuilds both indices.
43/// Clears parent, dependencies, and claim fields on the moved unit.
44///
45/// Returns a map of old_id -> new_id.
46pub fn move_units(source_dir: &Path, dest_dir: &Path, ids: &[String]) -> Result<MoveResult> {
47    let source_canonical = source_dir
48        .canonicalize()
49        .with_context(|| format!("Failed to resolve source path: {}", source_dir.display()))?;
50    let dest_canonical = dest_dir
51        .canonicalize()
52        .with_context(|| format!("Failed to resolve destination path: {}", dest_dir.display()))?;
53    if source_canonical == dest_canonical {
54        bail!("Source and destination are the same .mana/ directory");
55    }
56
57    let mut dest_config = Config::load(dest_dir).context("Failed to load destination config")?;
58
59    let mut id_map: HashMap<String, String> = HashMap::new();
60    let mut source_files_to_remove: Vec<PathBuf> = Vec::new();
61
62    for old_id in ids {
63        let source_path = find_unit_file(source_dir, old_id)
64            .with_context(|| format!("Unit '{}' not found in {}", old_id, source_dir.display()))?;
65        let mut unit = Unit::from_file(&source_path)
66            .with_context(|| format!("Failed to load unit '{}' from source", old_id))?;
67
68        let new_id = dest_config.increment_id().to_string();
69
70        unit.id = new_id.clone();
71        unit.updated_at = Utc::now();
72        unit.parent = None;
73        unit.dependencies.clear();
74        unit.claimed_by = None;
75        unit.claimed_at = None;
76
77        let slug = unit.slug.clone().unwrap_or_else(|| "unnamed".to_string());
78        let dest_filename = format!("{}-{}.md", new_id, slug);
79        let dest_path = dest_dir.join(&dest_filename);
80        unit.to_file(&dest_path)
81            .with_context(|| format!("Failed to write unit to {}", dest_path.display()))?;
82
83        source_files_to_remove.push(source_path);
84        id_map.insert(old_id.clone(), new_id);
85    }
86
87    dest_config
88        .save(dest_dir)
89        .context("Failed to save destination config")?;
90
91    for path in &source_files_to_remove {
92        fs::remove_file(path)
93            .with_context(|| format!("Failed to remove source file: {}", path.display()))?;
94    }
95
96    let dest_index = Index::build(dest_dir)?;
97    dest_index.save(dest_dir)?;
98
99    if source_dir.join("config.yaml").exists() {
100        let source_index = Index::build(source_dir)?;
101        source_index.save(source_dir)?;
102    }
103
104    Ok(MoveResult { id_map })
105}
106
107/// Move units from another project into this one.
108pub fn move_from(mana_dir: &Path, from: &str, ids: &[String]) -> Result<MoveResult> {
109    let from_path = PathBuf::from(from);
110    let source_dir = resolve_mana_dir(&from_path)
111        .with_context(|| format!("Failed to resolve --from: {}", from))?;
112    move_units(&source_dir, mana_dir, ids)
113}
114
115/// Move units from this project into another one.
116pub fn move_to(mana_dir: &Path, to: &str, ids: &[String]) -> Result<MoveResult> {
117    let to_path = PathBuf::from(to);
118    let dest_dir =
119        resolve_mana_dir(&to_path).with_context(|| format!("Failed to resolve --to: {}", to))?;
120    move_units(mana_dir, &dest_dir, ids)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::util::title_to_slug;
127    use tempfile::TempDir;
128
129    fn setup_mana_dir(name: &str) -> (TempDir, PathBuf) {
130        let dir = TempDir::new().unwrap();
131        let mana_dir = dir.path().join(".mana");
132        fs::create_dir(&mana_dir).unwrap();
133
134        Config {
135            project: name.to_string(),
136            next_id: 1,
137            auto_close_parent: true,
138            run: None,
139            plan: None,
140            max_loops: 10,
141            max_concurrent: 4,
142            poll_interval: 30,
143            extends: vec![],
144            rules_file: None,
145            file_locking: false,
146            worktree: false,
147            on_close: None,
148            on_fail: None,
149            post_plan: None,
150            verify_timeout: None,
151            review: None,
152            user: None,
153            user_email: None,
154            auto_commit: false,
155            commit_template: None,
156            research: None,
157            run_model: None,
158            plan_model: None,
159            review_model: None,
160            research_model: None,
161            batch_verify: false,
162            memory_reserve_mb: 0,
163            notify: None,
164        }
165        .save(&mana_dir)
166        .unwrap();
167
168        (dir, mana_dir)
169    }
170
171    fn create_test_unit(mana_dir: &Path, id: &str, title: &str) {
172        let mut unit = Unit::new(id, title);
173        unit.slug = Some(title_to_slug(title));
174        unit.verify = Some("true".to_string());
175        let slug = unit.slug.clone().unwrap();
176        unit.to_file(mana_dir.join(format!("{}-{}.md", id, slug)))
177            .unwrap();
178    }
179
180    #[test]
181    fn move_single_unit() {
182        let (_src_dir, src_units) = setup_mana_dir("source");
183        let (_dst_dir, dst_units) = setup_mana_dir("dest");
184
185        create_test_unit(&src_units, "1", "Fix login bug");
186
187        let result = move_units(&src_units, &dst_units, &["1".to_string()]).unwrap();
188
189        assert_eq!(result.id_map.get("1"), Some(&"1".to_string()));
190        assert!(!src_units.join("1-fix-login-bug.md").exists());
191        assert!(dst_units.join("1-fix-login-bug.md").exists());
192    }
193
194    #[test]
195    fn move_fails_for_same_directory() {
196        let (_dir, mana_dir) = setup_mana_dir("same");
197        create_test_unit(&mana_dir, "1", "Task");
198
199        let result = move_units(&mana_dir, &mana_dir, &["1".to_string()]);
200        assert!(result.is_err());
201    }
202
203    #[test]
204    fn move_clears_parent_and_deps() {
205        let (_src_dir, src_units) = setup_mana_dir("source");
206        let (_dst_dir, dst_units) = setup_mana_dir("dest");
207
208        let mut unit = Unit::new("1.1", "Child task");
209        unit.slug = Some("child-task".to_string());
210        unit.verify = Some("true".to_string());
211        unit.parent = Some("1".to_string());
212        unit.dependencies = vec!["5".to_string()];
213        unit.claimed_by = Some("agent-1".to_string());
214        unit.to_file(src_units.join("1.1-child-task.md")).unwrap();
215
216        let result = move_units(&src_units, &dst_units, &["1.1".to_string()]).unwrap();
217
218        let new_id = result.id_map.get("1.1").unwrap();
219        let moved = Unit::from_file(dst_units.join(format!("{}-child-task.md", new_id))).unwrap();
220
221        assert!(moved.parent.is_none());
222        assert!(moved.dependencies.is_empty());
223        assert!(moved.claimed_by.is_none());
224    }
225
226    #[test]
227    fn resolve_with_mana_dir() {
228        let dir = TempDir::new().unwrap();
229        let mana_dir = dir.path().join(".mana");
230        fs::create_dir(&mana_dir).unwrap();
231
232        let result = resolve_mana_dir(&mana_dir).unwrap();
233        assert_eq!(result, mana_dir);
234    }
235
236    #[test]
237    fn resolve_with_project_dir() {
238        let dir = TempDir::new().unwrap();
239        let mana_dir = dir.path().join(".mana");
240        fs::create_dir(&mana_dir).unwrap();
241
242        let result = resolve_mana_dir(dir.path()).unwrap();
243        assert_eq!(result, mana_dir);
244    }
245
246    #[test]
247    fn resolve_fails_for_no_units() {
248        let dir = TempDir::new().unwrap();
249        let result = resolve_mana_dir(dir.path());
250        assert!(result.is_err());
251    }
252}