Skip to main content

scud/commands/
migrate.rs

1use anyhow::Result;
2use colored::Colorize;
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use crate::models::task::{Task, TaskStatus};
7use crate::storage::Storage;
8
9/// Migrate task IDs to namespaced format and fix legacy patterns
10pub fn run(project_root: Option<PathBuf>, dry_run: bool) -> Result<()> {
11    let storage = Storage::new(project_root);
12
13    // Check if tasks file exists
14    let tasks_file = storage.tasks_file();
15    if !tasks_file.exists() {
16        println!("{}", "No tasks file found. Nothing to migrate.".yellow());
17        return Ok(());
18    }
19
20    let mut all_tasks = storage.load_tasks()?;
21    let mut changes: Vec<String> = Vec::new();
22    let mut parent_fixes = 0;
23    let mut subtask_links = 0;
24
25    for (epic_tag, epic) in all_tasks.iter_mut() {
26        let mut id_map: HashMap<String, String> = HashMap::new();
27
28        // Phase 1: Collect ID mappings for tasks that need namespacing
29        for task in &epic.tasks {
30            if !task.id.contains(':') {
31                let new_id = Task::make_id(epic_tag, &task.id);
32                id_map.insert(task.id.clone(), new_id.clone());
33                changes.push(format!("{}: {} -> {}", epic_tag, task.id, new_id));
34            }
35        }
36
37        // Phase 2: Update IDs and references
38        for task in &mut epic.tasks {
39            // Update task ID if it's not namespaced
40            if let Some(new_id) = id_map.get(&task.id) {
41                task.id = new_id.clone();
42            }
43
44            // Update dependencies to use namespaced IDs
45            task.dependencies = task
46                .dependencies
47                .iter()
48                .map(|dep| {
49                    id_map.get(dep).cloned().unwrap_or_else(|| {
50                        if dep.contains(':') {
51                            dep.clone()
52                        } else {
53                            Task::make_id(epic_tag, dep)
54                        }
55                    })
56                })
57                .collect();
58
59            // Update parent_id if present
60            if let Some(ref parent) = task.parent_id {
61                task.parent_id = Some(
62                    id_map
63                        .get(parent)
64                        .cloned()
65                        .unwrap_or_else(|| Task::make_id(epic_tag, parent)),
66                );
67            }
68
69            // Update subtask references
70            task.subtasks = task
71                .subtasks
72                .iter()
73                .map(|sub| {
74                    id_map
75                        .get(sub)
76                        .cloned()
77                        .unwrap_or_else(|| Task::make_id(epic_tag, sub))
78                })
79                .collect();
80
81            // Fix [PARENT] prefix -> Expanded status
82            if task.title.starts_with("[PARENT]") {
83                task.title = task.title.trim_start_matches("[PARENT]").trim().to_string();
84                task.status = TaskStatus::Expanded;
85                parent_fixes += 1;
86            }
87        }
88
89        // Phase 3: Infer parent-child relationships from ID patterns (e.g., 10.1 is subtask of 10)
90        // First pass: collect the relationships
91        let task_ids: Vec<String> = epic.tasks.iter().map(|t| t.id.clone()).collect();
92        let mut parent_child_links: Vec<(String, String)> = Vec::new(); // (child_id, parent_id)
93
94        for task in &epic.tasks {
95            // If this task looks like a subtask (contains dot in local_id) and has no parent_id
96            let local_id = task.local_id().to_string();
97            if local_id.contains('.') && task.parent_id.is_none() {
98                // Extract parent local ID (e.g., "10.1" -> "10")
99                if let Some(parent_local) = local_id.rsplit_once('.').map(|(p, _)| p.to_string()) {
100                    let parent_id = Task::make_id(epic_tag, &parent_local);
101                    if task_ids.contains(&parent_id) {
102                        parent_child_links.push((task.id.clone(), parent_id));
103                    }
104                }
105            }
106        }
107
108        // Second pass: apply the relationships
109        for (child_id, parent_id) in parent_child_links {
110            // Set parent_id on child
111            if let Some(child) = epic.tasks.iter_mut().find(|t| t.id == child_id) {
112                child.parent_id = Some(parent_id.clone());
113                subtask_links += 1;
114            }
115            // Add child to parent's subtasks
116            if let Some(parent) = epic.tasks.iter_mut().find(|t| t.id == parent_id) {
117                if !parent.subtasks.contains(&child_id) {
118                    parent.subtasks.push(child_id);
119                }
120            }
121        }
122    }
123
124    // Phase 4: Ensure parents with subtasks have Expanded status
125    for (_, epic) in all_tasks.iter_mut() {
126        let subtask_ids: Vec<String> = epic
127            .tasks
128            .iter()
129            .filter(|t| t.parent_id.is_some())
130            .filter_map(|t| t.parent_id.clone())
131            .collect();
132
133        for task in &mut epic.tasks {
134            if subtask_ids.contains(&task.id)
135                && task.status != TaskStatus::Expanded
136                && (task.status == TaskStatus::Pending || task.status == TaskStatus::InProgress)
137            {
138                task.status = TaskStatus::Expanded;
139                parent_fixes += 1;
140            }
141        }
142    }
143
144    if dry_run {
145        println!("{}", "Dry run - no changes made".yellow());
146        println!();
147
148        if changes.is_empty() && parent_fixes == 0 && subtask_links == 0 {
149            println!("{}", "No migrations needed. Data is up to date!".green());
150            return Ok(());
151        }
152
153        if !changes.is_empty() {
154            println!("{}", "ID changes:".blue().bold());
155            for change in &changes {
156                println!("  {}", change);
157            }
158            println!();
159        }
160
161        println!("{}", "Summary:".blue().bold());
162        println!("  {} ID namespacing changes", changes.len());
163        println!("  {} [PARENT] prefix fixes", parent_fixes);
164        println!("  {} subtask relationships inferred", subtask_links);
165    } else {
166        if changes.is_empty() && parent_fixes == 0 && subtask_links == 0 {
167            println!("{}", "No migrations needed. Data is up to date!".green());
168            return Ok(());
169        }
170
171        storage.save_tasks(&all_tasks)?;
172
173        println!("{}", "Migration complete!".green().bold());
174        println!();
175        println!("  {} task IDs namespaced", changes.len());
176        println!(
177            "  {} [PARENT] prefixes converted to Expanded status",
178            parent_fixes
179        );
180        println!("  {} subtask relationships established", subtask_links);
181        println!();
182        println!(
183            "{}",
184            "Tip: Run 'scud list' to verify the migration.".dimmed()
185        );
186    }
187
188    Ok(())
189}