mana_core/ops/
move_units.rs1use 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
13pub struct MoveResult {
15 pub id_map: HashMap<String, String>,
17}
18
19pub 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
39pub 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
107pub 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
115pub 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 verify_timeout: None,
150 review: None,
151 user: None,
152 user_email: None,
153 auto_commit: false,
154 commit_template: None,
155 research: None,
156 run_model: None,
157 plan_model: None,
158 review_model: None,
159 research_model: None,
160 batch_verify: false,
161 memory_reserve_mb: 0,
162 notify: None,
163 }
164 .save(&mana_dir)
165 .unwrap();
166
167 (dir, mana_dir)
168 }
169
170 fn create_test_unit(mana_dir: &Path, id: &str, title: &str) {
171 let mut unit = Unit::new(id, title);
172 unit.slug = Some(title_to_slug(title));
173 unit.verify = Some("true".to_string());
174 let slug = unit.slug.clone().unwrap();
175 unit.to_file(mana_dir.join(format!("{}-{}.md", id, slug)))
176 .unwrap();
177 }
178
179 #[test]
180 fn move_single_unit() {
181 let (_src_dir, src_units) = setup_mana_dir("source");
182 let (_dst_dir, dst_units) = setup_mana_dir("dest");
183
184 create_test_unit(&src_units, "1", "Fix login bug");
185
186 let result = move_units(&src_units, &dst_units, &["1".to_string()]).unwrap();
187
188 assert_eq!(result.id_map.get("1"), Some(&"1".to_string()));
189 assert!(!src_units.join("1-fix-login-bug.md").exists());
190 assert!(dst_units.join("1-fix-login-bug.md").exists());
191 }
192
193 #[test]
194 fn move_fails_for_same_directory() {
195 let (_dir, mana_dir) = setup_mana_dir("same");
196 create_test_unit(&mana_dir, "1", "Task");
197
198 let result = move_units(&mana_dir, &mana_dir, &["1".to_string()]);
199 assert!(result.is_err());
200 }
201
202 #[test]
203 fn move_clears_parent_and_deps() {
204 let (_src_dir, src_units) = setup_mana_dir("source");
205 let (_dst_dir, dst_units) = setup_mana_dir("dest");
206
207 let mut unit = Unit::new("1.1", "Child task");
208 unit.slug = Some("child-task".to_string());
209 unit.verify = Some("true".to_string());
210 unit.parent = Some("1".to_string());
211 unit.dependencies = vec!["5".to_string()];
212 unit.claimed_by = Some("agent-1".to_string());
213 unit.to_file(src_units.join("1.1-child-task.md")).unwrap();
214
215 let result = move_units(&src_units, &dst_units, &["1.1".to_string()]).unwrap();
216
217 let new_id = result.id_map.get("1.1").unwrap();
218 let moved = Unit::from_file(dst_units.join(format!("{}-child-task.md", new_id))).unwrap();
219
220 assert!(moved.parent.is_none());
221 assert!(moved.dependencies.is_empty());
222 assert!(moved.claimed_by.is_none());
223 }
224
225 #[test]
226 fn resolve_with_mana_dir() {
227 let dir = TempDir::new().unwrap();
228 let mana_dir = dir.path().join(".mana");
229 fs::create_dir(&mana_dir).unwrap();
230
231 let result = resolve_mana_dir(&mana_dir).unwrap();
232 assert_eq!(result, mana_dir);
233 }
234
235 #[test]
236 fn resolve_with_project_dir() {
237 let dir = TempDir::new().unwrap();
238 let mana_dir = dir.path().join(".mana");
239 fs::create_dir(&mana_dir).unwrap();
240
241 let result = resolve_mana_dir(dir.path()).unwrap();
242 assert_eq!(result, mana_dir);
243 }
244
245 #[test]
246 fn resolve_fails_for_no_units() {
247 let dir = TempDir::new().unwrap();
248 let result = resolve_mana_dir(dir.path());
249 assert!(result.is_err());
250 }
251}