Skip to main content

mana_core/ops/
reparent.rs

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, &current_id)
103            .with_context(|| format!("Parent chain unit not found: {}", current_id))?;
104        current = Unit::from_file(&current_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}