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#[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 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 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 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 let mut fixes_applied: HashMap<String, Vec<String>> = HashMap::new();
98
99 println!("{}", "Phase 1: Automatic Fixes".blue().bold());
101 println!("{}", "-".repeat(40).blue());
102
103 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 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 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 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 println!("{}", "Phase 2: AI Dependency Analysis".blue().bold());
190 println!("{}", "-".repeat(40).blue());
191
192 let task_context = build_task_context(&all_phases, &phases_to_check);
194
195 let client = match project_root.clone() {
197 Some(root) => LLMClient::new_with_project_root(root)?,
198 None => LLMClient::new()?,
199 };
200
201 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 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 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 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 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 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 for dep in &suggestion.add_dependencies {
278 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 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 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 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}