1use std::path::{Path, PathBuf};
2
3use anyhow::{anyhow, bail, Context, Result};
4use chrono::Utc;
5
6use crate::discovery::find_unit_file;
7use crate::hooks::{execute_hook, HookEvent};
8use crate::index::{Index, LockedIndex};
9use crate::unit::Unit;
10
11#[derive(Debug, Clone, Default)]
12pub struct ReparentParams {
13 pub parent: Option<String>,
14 pub reason: Option<String>,
15}
16
17#[derive(Debug, Clone, serde::Serialize)]
18pub struct ReparentResult {
19 pub unit: Unit,
20 pub path: PathBuf,
21 pub old_parent: Option<String>,
22 pub new_parent: Option<String>,
23 pub reason: Option<String>,
24}
25
26pub fn reparent(mana_dir: &Path, id: &str, params: ReparentParams) -> Result<ReparentResult> {
27 let unit_path =
28 find_unit_file(mana_dir, id).with_context(|| format!("Unit not found: {}", id))?;
29 let mut unit =
30 Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
31
32 if matches!(params.parent.as_deref(), Some(parent) if parent == id) {
33 bail!("A unit cannot be its own parent");
34 }
35
36 if let Some(parent_id) = params.parent.as_deref() {
37 find_unit_file(mana_dir, parent_id)
38 .with_context(|| format!("Parent unit not found: {}", parent_id))?;
39 ensure_not_descendant(mana_dir, id, parent_id)?;
40 }
41
42 let project_root = mana_dir
43 .parent()
44 .ok_or_else(|| anyhow!("Cannot determine project root from units dir"))?;
45
46 let pre_passed = execute_hook(HookEvent::PreUpdate, &unit, project_root, None)
47 .context("Pre-update hook execution failed")?;
48 if !pre_passed {
49 return Err(anyhow!("Pre-update hook rejected unit reparent"));
50 }
51
52 let old_parent = unit.parent.clone();
53 let new_parent = params.parent.filter(|parent| !parent.trim().is_empty());
54 unit.parent = new_parent.clone();
55 unit.updated_at = Utc::now();
56 if let Some(reason) = params
57 .reason
58 .as_deref()
59 .map(str::trim)
60 .filter(|s| !s.is_empty())
61 {
62 let timestamp = Utc::now().to_rfc3339();
63 let note = format!(
64 "---\n{}\nReparented from {} to {}: {}",
65 timestamp,
66 old_parent.as_deref().unwrap_or("<root>"),
67 new_parent.as_deref().unwrap_or("<root>"),
68 reason
69 );
70 unit.notes = Some(match unit.notes.take() {
71 Some(existing) => format!("{}\n\n{}", existing, note),
72 None => note,
73 });
74 }
75
76 unit.to_file(&unit_path)
77 .with_context(|| format!("Failed to save unit: {}", id))?;
78
79 let mut locked = LockedIndex::acquire(mana_dir)?;
80 locked.index = Index::build(mana_dir)?;
81 locked.save_and_release()?;
82
83 if let Err(e) = execute_hook(HookEvent::PostUpdate, &unit, project_root, None) {
84 eprintln!("Warning: post-update hook failed: {}", e);
85 }
86
87 Ok(ReparentResult {
88 unit,
89 path: unit_path,
90 old_parent,
91 new_parent,
92 reason: params.reason,
93 })
94}
95
96fn ensure_not_descendant(mana_dir: &Path, id: &str, proposed_parent: &str) -> Result<()> {
97 let mut current = Some(proposed_parent.to_string());
98 while let Some(current_id) = current {
99 if current_id == id {
100 bail!("Cannot reparent unit under its own descendant");
101 }
102 let current_path = find_unit_file(mana_dir, ¤t_id)
103 .with_context(|| format!("Parent chain unit not found: {}", current_id))?;
104 current = Unit::from_file(¤t_path)?.parent;
105 }
106 Ok(())
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use crate::ops::create::{self, CreateParams};
113 use std::fs;
114 use tempfile::TempDir;
115
116 fn setup() -> (TempDir, PathBuf) {
117 let dir = TempDir::new().unwrap();
118 let mana_dir = dir.path().join(".mana");
119 fs::create_dir(&mana_dir).unwrap();
120 crate::config::Config {
121 project: "test".to_string(),
122 next_id: 1,
123 auto_close_parent: true,
124 run: None,
125 plan: None,
126 max_loops: 10,
127 max_concurrent: 4,
128 poll_interval: 30,
129 extends: vec![],
130 rules_file: None,
131 file_locking: false,
132 worktree: false,
133 on_close: None,
134 on_fail: None,
135 verify_timeout: None,
136 review: None,
137 user: None,
138 user_email: None,
139 auto_commit: false,
140 commit_template: None,
141 research: None,
142 run_model: None,
143 plan_model: None,
144 review_model: None,
145 research_model: None,
146 batch_verify: false,
147 memory_reserve_mb: 0,
148 notify: None,
149 }
150 .save(&mana_dir)
151 .unwrap();
152 (dir, mana_dir)
153 }
154
155 fn create_unit(mana_dir: &Path, title: &str, parent: Option<&str>) -> String {
156 create::create(
157 mana_dir,
158 CreateParams {
159 title: title.to_string(),
160 parent: parent.map(str::to_string),
161 ..Default::default()
162 },
163 )
164 .unwrap()
165 .unit
166 .id
167 }
168
169 #[test]
170 fn reparent_moves_child_between_parents_and_rebuilds_index() {
171 let (_dir, mana_dir) = setup();
172 let old_parent = create_unit(&mana_dir, "Old Parent", None);
173 let new_parent = create_unit(&mana_dir, "New Parent", None);
174 let child = create_unit(&mana_dir, "Child", Some(&old_parent));
175
176 let result = reparent(
177 &mana_dir,
178 &child,
179 ReparentParams {
180 parent: Some(new_parent.clone()),
181 reason: Some("Wrong active epic".to_string()),
182 },
183 )
184 .unwrap();
185
186 assert_eq!(result.old_parent.as_deref(), Some(old_parent.as_str()));
187 assert_eq!(result.new_parent.as_deref(), Some(new_parent.as_str()));
188 assert_eq!(result.unit.parent.as_deref(), Some(new_parent.as_str()));
189 assert!(result.unit.notes.unwrap().contains("Wrong active epic"));
190
191 let index = Index::load_or_rebuild(&mana_dir).unwrap();
192 let child_entry = index.units.iter().find(|entry| entry.id == child).unwrap();
193 assert_eq!(child_entry.parent.as_deref(), Some(new_parent.as_str()));
194 assert!(
195 !index
196 .units
197 .iter()
198 .any(|entry| entry.id == child
199 && entry.parent.as_deref() == Some(old_parent.as_str()))
200 );
201 }
202
203 #[test]
204 fn reparent_rejects_missing_parent_and_cycles() {
205 let (_dir, mana_dir) = setup();
206 let parent = create_unit(&mana_dir, "Parent", None);
207 let child = create_unit(&mana_dir, "Child", Some(&parent));
208
209 let missing = reparent(
210 &mana_dir,
211 &child,
212 ReparentParams {
213 parent: Some("404".to_string()),
214 reason: None,
215 },
216 )
217 .unwrap_err();
218 assert!(missing.to_string().contains("Parent unit not found"));
219
220 let cycle = reparent(
221 &mana_dir,
222 &parent,
223 ReparentParams {
224 parent: Some(child),
225 reason: None,
226 },
227 )
228 .unwrap_err();
229 assert!(cycle.to_string().contains("own descendant"));
230 }
231}