scud/commands/
fix_deps.rs

1use anyhow::Result;
2use colored::Colorize;
3use indicatif::{ProgressBar, ProgressStyle};
4use serde::Deserialize;
5use std::collections::{HashMap, HashSet};
6use std::path::PathBuf;
7
8use crate::llm::{LLMClient, Prompts};
9use crate::models::{Phase, TaskStatus};
10use crate::storage::Storage;
11
12use super::check_deps::{validate_phase, DepCheckResults};
13
14/// Suggested fix from AI analysis
15#[derive(Debug, Deserialize)]
16pub struct DepFix {
17    pub task_id: String,
18    #[serde(default)]
19    pub add_dependencies: Vec<String>,
20    #[serde(default)]
21    pub remove_dependencies: Vec<String>,
22    pub reasoning: String,
23}
24
25pub async fn run(
26    project_root: Option<PathBuf>,
27    tag: Option<&str>,
28    all_tags: bool,
29    dry_run: bool,
30    model: Option<&str>,
31) -> Result<()> {
32    let storage = Storage::new(project_root.clone());
33
34    if !storage.is_initialized() {
35        anyhow::bail!("SCUD not initialized. Run: scud init");
36    }
37
38    let mut all_phases = storage.load_tasks()?;
39
40    if all_phases.is_empty() {
41        println!("{}", "No tasks found.".yellow());
42        return Ok(());
43    }
44
45    // Determine which phases to fix
46    let phases_to_check: Vec<String> = match tag {
47        Some(t) if !all_tags => {
48            if !all_phases.contains_key(t) {
49                anyhow::bail!("Tag '{}' not found", t);
50            }
51            vec![t.to_string()]
52        }
53        _ => all_phases.keys().cloned().collect(),
54    };
55
56    println!(
57        "{} Analyzing dependencies across {} phase(s)...\n",
58        "Analyzing".blue(),
59        phases_to_check.len()
60    );
61
62    // Build global task ID set for cross-phase validation
63    let all_task_ids: HashSet<String> = all_phases
64        .iter()
65        .flat_map(|(tag, phase)| {
66            phase.tasks.iter().flat_map(move |t| {
67                let mut ids = vec![t.id.clone(), format!("{}:{}", tag, t.id)];
68                for subtask_id in &t.subtasks {
69                    ids.push(subtask_id.clone());
70                    ids.push(format!("{}:{}", tag, subtask_id));
71                }
72                ids
73            })
74        })
75        .collect();
76
77    // First, run check_deps validation to identify issues
78    let mut results = DepCheckResults::default();
79    for tag in &phases_to_check {
80        if let Some(phase) = all_phases.get(tag) {
81            validate_phase(tag, phase, &all_task_ids, &mut results);
82        }
83    }
84
85    if !results.has_issues() {
86        println!("{}", "✓ No dependency issues found!".green().bold());
87        return Ok(());
88    }
89
90    println!(
91        "{} Found {} issue(s) to fix\n",
92        "Found".yellow(),
93        results.issue_count()
94    );
95
96    // Track fixes applied
97    let mut fixes_applied: HashMap<String, Vec<String>> = HashMap::new();
98
99    // Phase 1: Apply automatic fixes (removals)
100    println!("{}", "Phase 1: Automatic Fixes".blue().bold());
101    println!("{}", "-".repeat(40).blue());
102
103    // Fix invalid zero references
104    for (tag, task_id) in &results.invalid_zero_deps {
105        let full_id = format!("{}:{}", tag, task_id);
106        if let Some(phase) = all_phases.get_mut(tag) {
107            if let Some(task) = phase.tasks.iter_mut().find(|t| &t.id == task_id) {
108                let before_len = task.dependencies.len();
109                task.dependencies.retain(|d| d != "0" && !d.ends_with(":0"));
110                let removed = before_len - task.dependencies.len();
111                if removed > 0 {
112                    let msg = format!("Removed {} invalid '0' reference(s)", removed);
113                    println!("  {} {} - {}", "✓".green(), full_id.cyan(), msg);
114                    fixes_applied
115                        .entry(full_id.clone())
116                        .or_default()
117                        .push(msg);
118                }
119            }
120        }
121    }
122
123    // Fix self-references
124    for (tag, task_id) in &results.self_refs {
125        let full_id = format!("{}:{}", tag, task_id);
126        if let Some(phase) = all_phases.get_mut(tag) {
127            if let Some(task) = phase.tasks.iter_mut().find(|t| &t.id == task_id) {
128                let self_ref = task_id.clone();
129                let self_ref_full = format!("{}:{}", tag, task_id);
130                let before_len = task.dependencies.len();
131                task.dependencies
132                    .retain(|d| d != &self_ref && d != &self_ref_full);
133                let removed = before_len - task.dependencies.len();
134                if removed > 0 {
135                    let msg = "Removed self-reference".to_string();
136                    println!("  {} {} - {}", "✓".green(), full_id.cyan(), msg);
137                    fixes_applied
138                        .entry(full_id.clone())
139                        .or_default()
140                        .push(msg);
141                }
142            }
143        }
144    }
145
146    // Fix missing dependencies (remove them)
147    for (tag, task_id, missing_dep) in &results.missing_deps {
148        let full_id = format!("{}:{}", tag, task_id);
149        if let Some(phase) = all_phases.get_mut(tag) {
150            if let Some(task) = phase.tasks.iter_mut().find(|t| &t.id == task_id) {
151                let before_len = task.dependencies.len();
152                task.dependencies.retain(|d| d != missing_dep);
153                let removed = before_len - task.dependencies.len();
154                if removed > 0 {
155                    let msg = format!("Removed non-existent dependency '{}'", missing_dep);
156                    println!("  {} {} - {}", "✓".green(), full_id.cyan(), msg);
157                    fixes_applied
158                        .entry(full_id.clone())
159                        .or_default()
160                        .push(msg);
161                }
162            }
163        }
164    }
165
166    // Fix cancelled dependencies (remove them)
167    for (tag, task_id, cancelled_dep) in &results.cancelled_deps {
168        let full_id = format!("{}:{}", tag, task_id);
169        if let Some(phase) = all_phases.get_mut(tag) {
170            if let Some(task) = phase.tasks.iter_mut().find(|t| &t.id == task_id) {
171                let before_len = task.dependencies.len();
172                task.dependencies.retain(|d| d != cancelled_dep);
173                let removed = before_len - task.dependencies.len();
174                if removed > 0 {
175                    let msg = format!("Removed dependency on cancelled task '{}'", cancelled_dep);
176                    println!("  {} {} - {}", "✓".green(), full_id.cyan(), msg);
177                    fixes_applied
178                        .entry(full_id.clone())
179                        .or_default()
180                        .push(msg);
181                }
182            }
183        }
184    }
185
186    println!();
187
188    // Phase 2: AI-powered dependency suggestions
189    println!("{}", "Phase 2: AI Dependency Analysis".blue().bold());
190    println!("{}", "-".repeat(40).blue());
191
192    // Build task context for AI
193    let task_context = build_task_context(&all_phases, &phases_to_check);
194
195    // Create LLM client
196    let client = match project_root.clone() {
197        Some(root) => LLMClient::new_with_project_root(root)?,
198        None => LLMClient::new()?,
199    };
200
201    // Show progress
202    let spinner = ProgressBar::new_spinner();
203    spinner.set_style(
204        ProgressStyle::default_spinner()
205            .template("{spinner:.blue} {msg}")
206            .unwrap(),
207    );
208    spinner.set_message("Analyzing dependencies with AI...");
209    spinner.enable_steady_tick(std::time::Duration::from_millis(100));
210
211    // Call LLM to suggest fixes (use smart model for analysis tasks)
212    let prompt = Prompts::reanalyze_dependencies(&task_context, &phases_to_check);
213    let suggestions: Vec<DepFix> = client.complete_json_smart(&prompt, model).await?;
214
215    spinner.finish_and_clear();
216
217    if suggestions.is_empty() {
218        println!("  {} No additional dependency changes suggested by AI", "ℹ".blue());
219    } else {
220        println!(
221            "  {} AI suggested {} change(s):\n",
222            "ℹ".blue(),
223            suggestions.len()
224        );
225
226        for suggestion in &suggestions {
227            // Parse task_id to get tag and id
228            let (suggestion_tag, suggestion_task_id) = if suggestion.task_id.contains(':') {
229                let parts: Vec<&str> = suggestion.task_id.split(':').collect();
230                (parts[0].to_string(), parts[1].to_string())
231            } else {
232                // Assume current tag if not specified
233                let tag = phases_to_check.first().cloned().unwrap_or_default();
234                (tag, suggestion.task_id.clone())
235            };
236
237            println!("  {} {}", "→".cyan(), suggestion.task_id.cyan().bold());
238
239            if !suggestion.add_dependencies.is_empty() {
240                println!(
241                    "    {} {}",
242                    "Add:".green(),
243                    suggestion.add_dependencies.join(", ").green()
244                );
245            }
246
247            if !suggestion.remove_dependencies.is_empty() {
248                println!(
249                    "    {} {}",
250                    "Remove:".red(),
251                    suggestion.remove_dependencies.join(", ").red()
252                );
253            }
254
255            println!("    {} {}", "Reason:".dimmed(), suggestion.reasoning.dimmed());
256
257            // Apply AI suggestions
258            if let Some(phase) = all_phases.get_mut(&suggestion_tag) {
259                if let Some(task) = phase
260                    .tasks
261                    .iter_mut()
262                    .find(|t| t.id == suggestion_task_id)
263                {
264                    // Skip completed/cancelled tasks
265                    if matches!(task.status, TaskStatus::Done | TaskStatus::Cancelled) {
266                        println!(
267                            "    {} Task is {} - skipping",
268                            "⚠".yellow(),
269                            format!("{:?}", task.status).yellow()
270                        );
271                        continue;
272                    }
273
274                    let mut changes = Vec::new();
275
276                    // Add new dependencies
277                    for dep in &suggestion.add_dependencies {
278                        // Validate the dependency exists and isn't task 0
279                        if dep == "0" || dep.ends_with(":0") {
280                            println!(
281                                "    {} Skipping invalid '0' dependency suggestion",
282                                "⚠".yellow()
283                            );
284                            continue;
285                        }
286
287                        if !task.dependencies.contains(dep) && all_task_ids.contains(dep) {
288                            task.dependencies.push(dep.clone());
289                            changes.push(format!("Added dependency on '{}'", dep));
290                        }
291                    }
292
293                    // Remove dependencies
294                    for dep in &suggestion.remove_dependencies {
295                        if task.dependencies.contains(dep) {
296                            task.dependencies.retain(|d| d != dep);
297                            changes.push(format!("Removed dependency on '{}'", dep));
298                        }
299                    }
300
301                    if !changes.is_empty() {
302                        let full_id = format!("{}:{}", suggestion_tag, suggestion_task_id);
303                        fixes_applied
304                            .entry(full_id)
305                            .or_default()
306                            .extend(changes);
307                    }
308                }
309            }
310
311            println!();
312        }
313    }
314
315    // Summary
316    println!();
317    println!("{}", "Fix Summary".blue().bold());
318    println!("{}", "-".repeat(40).blue());
319
320    let total_tasks_fixed = fixes_applied.len();
321    let total_changes: usize = fixes_applied.values().map(|v| v.len()).sum();
322
323    if total_changes == 0 {
324        println!("  No changes needed.");
325    } else {
326        println!(
327            "  {} task(s) with {} total change(s)",
328            total_tasks_fixed.to_string().green(),
329            total_changes.to_string().green()
330        );
331
332        if dry_run {
333            println!();
334            println!(
335                "{}",
336                "DRY RUN - no changes saved. Remove --dry-run to apply.".yellow()
337            );
338        } else {
339            // Save changes
340            storage.save_tasks(&all_phases)?;
341            println!();
342            println!("{}", "✓ Changes saved successfully!".green().bold());
343        }
344    }
345
346    println!();
347    println!("{}", "Next steps:".blue());
348    println!("  1. Review changes: scud list -v");
349    println!("  2. Validate: scud check-deps");
350    println!("  3. View dependency graph: scud mermaid");
351
352    Ok(())
353}
354
355fn build_task_context(
356    all_phases: &HashMap<String, Phase>,
357    phases_to_check: &[String],
358) -> String {
359    let mut context = String::new();
360
361    for tag in phases_to_check {
362        if let Some(phase) = all_phases.get(tag) {
363            context.push_str(&format!("## Phase: {}\n\n", tag));
364
365            for task in &phase.tasks {
366                let deps_str = if task.dependencies.is_empty() {
367                    "none".to_string()
368                } else {
369                    task.dependencies.join(", ")
370                };
371
372                context.push_str(&format!(
373                    "- {}:{} [{}] - {}\n  Dependencies: {}\n",
374                    tag,
375                    task.id,
376                    format!("{:?}", task.status),
377                    task.title,
378                    deps_str
379                ));
380            }
381
382            context.push('\n');
383        }
384    }
385
386    context
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::models::Task;
393
394    #[test]
395    fn test_build_task_context() {
396        let mut phase = Phase::new("test".to_string());
397        let mut task1 = Task::new("1".to_string(), "First task".to_string(), "".to_string());
398        task1.dependencies = vec![];
399        let mut task2 = Task::new("2".to_string(), "Second task".to_string(), "".to_string());
400        task2.dependencies = vec!["1".to_string()];
401
402        phase.add_task(task1);
403        phase.add_task(task2);
404
405        let mut phases = HashMap::new();
406        phases.insert("test".to_string(), phase);
407
408        let context = build_task_context(&phases, &["test".to_string()]);
409
410        assert!(context.contains("## Phase: test"));
411        assert!(context.contains("test:1"));
412        assert!(context.contains("test:2"));
413        assert!(context.contains("Dependencies: 1"));
414    }
415}