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
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 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}