scud/commands/
check_deps.rs

1use anyhow::Result;
2use colored::Colorize;
3use indicatif::{ProgressBar, ProgressStyle};
4use serde::{Deserialize, Serialize};
5use std::collections::HashSet;
6use std::path::{Path, PathBuf};
7
8use crate::llm::{LLMClient, Prompts};
9use crate::models::{Phase, TaskStatus};
10use crate::storage::Storage;
11
12/// Results from dependency validation
13#[derive(Debug, Default)]
14pub struct DepCheckResults {
15    pub missing_deps: Vec<(String, String, String)>, // (tag, task_id, missing_dep)
16    pub invalid_zero_deps: Vec<(String, String)>,    // (tag, task_id)
17    pub self_refs: Vec<(String, String)>,            // (tag, task_id)
18    pub cancelled_deps: Vec<(String, String, String)>, // (tag, task_id, cancelled_dep)
19}
20
21impl DepCheckResults {
22    pub fn has_issues(&self) -> bool {
23        !self.missing_deps.is_empty()
24            || !self.invalid_zero_deps.is_empty()
25            || !self.self_refs.is_empty()
26            || !self.cancelled_deps.is_empty()
27    }
28
29    pub fn issue_count(&self) -> usize {
30        self.missing_deps.len()
31            + self.invalid_zero_deps.len()
32            + self.self_refs.len()
33            + self.cancelled_deps.len()
34    }
35}
36
37/// PRD validation result structures
38#[derive(Debug, Deserialize, Serialize)]
39pub struct PrdValidationResult {
40    pub coverage_score: u32,
41    #[serde(default)]
42    pub missing_requirements: Vec<MissingRequirement>,
43    #[serde(default)]
44    pub incomplete_coverage: Vec<IncompleteCoverage>,
45    #[serde(default)]
46    pub misaligned_tasks: Vec<MisalignedTask>,
47    #[serde(default)]
48    pub extra_tasks: Vec<ExtraTask>,
49    #[serde(default)]
50    pub dependency_suggestions: Vec<DependencySuggestion>,
51    pub summary: String,
52}
53
54#[derive(Debug, Deserialize, Serialize)]
55pub struct MissingRequirement {
56    pub requirement: String,
57    pub prd_section: String,
58    pub suggested_task: String,
59}
60
61#[derive(Debug, Deserialize, Serialize)]
62pub struct IncompleteCoverage {
63    pub requirement: String,
64    pub existing_tasks: Vec<String>,
65    pub gap: String,
66}
67
68#[derive(Debug, Deserialize, Serialize)]
69pub struct MisalignedTask {
70    pub task_id: String,
71    pub issue: String,
72    pub suggestion: String,
73}
74
75#[derive(Debug, Deserialize, Serialize)]
76pub struct ExtraTask {
77    pub task_id: String,
78    pub note: String,
79}
80
81#[derive(Debug, Deserialize, Serialize)]
82pub struct DependencySuggestion {
83    pub task_id: String,
84    pub should_depend_on: Vec<String>,
85    pub reasoning: String,
86}
87
88/// Struct for PRD fix response
89#[derive(Debug, Deserialize)]
90pub struct PrdFix {
91    pub action: String, // "update_task", "add_task", "update_dependency"
92    pub task_id: Option<String>,
93    pub new_title: Option<String>,
94    pub new_description: Option<String>,
95    pub add_dependencies: Option<Vec<String>>,
96    pub remove_dependencies: Option<Vec<String>>,
97    pub reasoning: String,
98}
99
100pub async fn run(
101    project_root: Option<PathBuf>,
102    tag: Option<&str>,
103    all_tags: bool,
104    prd_file: Option<&Path>,
105    fix: bool,
106    model: Option<&str>,
107) -> Result<()> {
108    let storage = Storage::new(project_root.clone());
109
110    if !storage.is_initialized() {
111        anyhow::bail!("SCUD not initialized. Run: scud init");
112    }
113
114    // Validate --fix requires --prd
115    if fix && prd_file.is_none() {
116        anyhow::bail!("--fix requires --prd to be specified");
117    }
118
119    let mut all_phases = storage.load_tasks()?;
120
121    if all_phases.is_empty() {
122        println!("{}", "No tasks found.".yellow());
123        return Ok(());
124    }
125
126    // Determine which phases to check
127    let phases_to_check: Vec<String> = match tag {
128        Some(t) if !all_tags => {
129            if !all_phases.contains_key(t) {
130                anyhow::bail!("Tag '{}' not found", t);
131            }
132            vec![t.to_string()]
133        }
134        _ => all_phases.keys().cloned().collect(),
135    };
136
137    println!(
138        "{} Checking dependencies across {} phase(s)...\n",
139        "Validating".blue(),
140        phases_to_check.len()
141    );
142
143    let mut results = DepCheckResults::default();
144
145    // Build global task ID set for cross-phase validation
146    let all_task_ids: HashSet<String> = all_phases
147        .iter()
148        .flat_map(|(tag, phase)| {
149            phase.tasks.iter().flat_map(move |t| {
150                let mut ids = vec![t.id.clone(), format!("{}:{}", tag, t.id)];
151                // Also add subtask IDs if present
152                for subtask_id in &t.subtasks {
153                    ids.push(subtask_id.clone());
154                    ids.push(format!("{}:{}", tag, subtask_id));
155                }
156                ids
157            })
158        })
159        .collect();
160
161    // Validate each phase
162    for tag in &phases_to_check {
163        if let Some(phase) = all_phases.get(tag) {
164            validate_phase(tag, phase, &all_task_ids, &mut results);
165        }
166    }
167
168    // Print dependency results
169    print_dep_results(&results);
170
171    let mut has_prd_issues = false;
172
173    // PRD validation if file provided
174    if let Some(prd_path) = prd_file {
175        println!();
176        println!("{}", "━".repeat(50).blue());
177        println!("{}", "PRD Coverage Validation".blue().bold());
178        println!("{}", "━".repeat(50).blue());
179        println!();
180
181        // Read PRD file
182        let prd_content = storage.read_file(prd_path)?;
183
184        // Build tasks JSON for LLM
185        let tasks_json = build_tasks_json(&all_phases, &phases_to_check);
186
187        // Create LLM client
188        let client = match project_root {
189            Some(root) => LLMClient::new_with_project_root(root)?,
190            None => LLMClient::new()?,
191        };
192
193        // Show model info
194        let model_info = client.smart_model_info(model);
195        println!("{} {}", "Using".blue(), model_info.to_string().cyan());
196        println!();
197
198        // Show progress
199        let spinner = ProgressBar::new_spinner();
200        spinner.set_style(
201            ProgressStyle::default_spinner()
202                .template("{spinner:.blue} {msg}")
203                .unwrap(),
204        );
205        spinner.set_message("Validating tasks against PRD with AI...");
206        spinner.enable_steady_tick(std::time::Duration::from_millis(100));
207
208        // Call LLM to validate (use smart model for validation tasks)
209        let prompt = Prompts::validate_tasks_against_prd(&prd_content, &tasks_json);
210        let validation: PrdValidationResult = client.complete_json_smart(&prompt, model).await?;
211
212        spinner.finish_and_clear();
213
214        // Print PRD validation results
215        has_prd_issues = print_prd_results(&validation);
216
217        // Apply fixes if requested
218        if fix && has_prd_issues {
219            println!();
220            println!("{}", "━".repeat(50).green());
221            println!("{}", "Applying PRD Fixes".green().bold());
222            println!("{}", "━".repeat(50).green());
223            println!();
224
225            // Show progress for fix generation
226            let fix_spinner = ProgressBar::new_spinner();
227            fix_spinner.set_style(
228                ProgressStyle::default_spinner()
229                    .template("{spinner:.green} {msg}")
230                    .unwrap(),
231            );
232            fix_spinner.set_message("Generating fixes based on PRD validation...");
233            fix_spinner.enable_steady_tick(std::time::Duration::from_millis(100));
234
235            // Call LLM to generate fixes
236            let fix_prompt = Prompts::fix_prd_issues(&prd_content, &tasks_json, &validation);
237            let fixes: Vec<PrdFix> = client.complete_json_smart(&fix_prompt, model).await?;
238
239            fix_spinner.finish_and_clear();
240
241            if fixes.is_empty() {
242                println!("  {} No automatic fixes available", "ℹ".blue());
243            } else {
244                println!("  {} Generated {} fix(es):\n", "✓".green(), fixes.len());
245
246                let mut changes_made = 0;
247
248                for fix_item in &fixes {
249                    println!("  {} {}", "→".cyan(), fix_item.action.cyan().bold());
250                    println!("    {}", fix_item.reasoning.dimmed());
251
252                    match fix_item.action.as_str() {
253                        "update_task" => {
254                            if let Some(task_id) = &fix_item.task_id {
255                                // Parse tag:id format
256                                let (fix_tag, fix_task_id) =
257                                    parse_task_id(task_id, &phases_to_check);
258
259                                if let Some(phase) = all_phases.get_mut(&fix_tag) {
260                                    if let Some(task) =
261                                        phase.tasks.iter_mut().find(|t| t.id == fix_task_id)
262                                    {
263                                        if let Some(new_title) = &fix_item.new_title {
264                                            println!("    {} {}", "Title:".green(), new_title);
265                                            task.title = new_title.clone();
266                                            changes_made += 1;
267                                        }
268                                        if let Some(new_desc) = &fix_item.new_description {
269                                            println!(
270                                                "    {} {} chars",
271                                                "Description:".green(),
272                                                new_desc.len()
273                                            );
274                                            task.description = new_desc.clone();
275                                            changes_made += 1;
276                                        }
277                                    }
278                                }
279                            }
280                        }
281                        "update_dependency" => {
282                            if let Some(task_id) = &fix_item.task_id {
283                                let (fix_tag, fix_task_id) =
284                                    parse_task_id(task_id, &phases_to_check);
285
286                                if let Some(phase) = all_phases.get_mut(&fix_tag) {
287                                    if let Some(task) =
288                                        phase.tasks.iter_mut().find(|t| t.id == fix_task_id)
289                                    {
290                                        if let Some(add_deps) = &fix_item.add_dependencies {
291                                            for dep in add_deps {
292                                                if !task.dependencies.contains(dep) {
293                                                    println!("    {} +{}", "Dep:".green(), dep);
294                                                    task.dependencies.push(dep.clone());
295                                                    changes_made += 1;
296                                                }
297                                            }
298                                        }
299                                        if let Some(remove_deps) = &fix_item.remove_dependencies {
300                                            for dep in remove_deps {
301                                                if task.dependencies.contains(dep) {
302                                                    println!("    {} -{}", "Dep:".red(), dep);
303                                                    task.dependencies.retain(|d| d != dep);
304                                                    changes_made += 1;
305                                                }
306                                            }
307                                        }
308                                    }
309                                }
310                            }
311                        }
312                        _ => {
313                            println!(
314                                "    {} Unsupported action: {}",
315                                "⚠".yellow(),
316                                fix_item.action
317                            );
318                        }
319                    }
320                    println!();
321                }
322
323                if changes_made > 0 {
324                    storage.save_tasks(&all_phases)?;
325                    println!(
326                        "{}",
327                        format!("✓ Applied {} change(s) successfully!", changes_made)
328                            .green()
329                            .bold()
330                    );
331                    has_prd_issues = false; // Don't exit with error since we fixed things
332                } else {
333                    println!(
334                        "  {} No changes could be applied automatically",
335                        "ℹ".yellow()
336                    );
337                    println!(
338                        "  {} Some issues may require manual intervention",
339                        "ℹ".yellow()
340                    );
341                }
342            }
343        }
344    }
345
346    if results.has_issues() || has_prd_issues {
347        std::process::exit(1);
348    }
349
350    Ok(())
351}
352
353/// Parse task_id in format "tag:id" or just "id"
354fn parse_task_id(task_id: &str, phases_to_check: &[String]) -> (String, String) {
355    if task_id.contains(':') {
356        let parts: Vec<&str> = task_id.split(':').collect();
357        (parts[0].to_string(), parts[1..].join(":"))
358    } else {
359        let tag = phases_to_check.first().cloned().unwrap_or_default();
360        (tag, task_id.to_string())
361    }
362}
363
364fn build_tasks_json(
365    all_phases: &std::collections::HashMap<String, Phase>,
366    phases_to_check: &[String],
367) -> String {
368    let mut tasks_list = Vec::new();
369
370    for tag in phases_to_check {
371        if let Some(phase) = all_phases.get(tag) {
372            for task in &phase.tasks {
373                tasks_list.push(serde_json::json!({
374                    "id": format!("{}:{}", tag, task.id),
375                    "title": task.title,
376                    "description": task.description,
377                    "status": format!("{:?}", task.status),
378                    "priority": format!("{:?}", task.priority),
379                    "complexity": task.complexity,
380                    "dependencies": task.dependencies,
381                }));
382            }
383        }
384    }
385
386    serde_json::to_string_pretty(&tasks_list).unwrap_or_else(|_| "[]".to_string())
387}
388
389pub fn validate_phase(
390    tag: &str,
391    phase: &Phase,
392    all_task_ids: &HashSet<String>,
393    results: &mut DepCheckResults,
394) {
395    let local_ids: HashSet<_> = phase.tasks.iter().map(|t| t.id.clone()).collect();
396
397    for task in &phase.tasks {
398        // Skip completed/cancelled tasks
399        if matches!(task.status, TaskStatus::Done | TaskStatus::Cancelled) {
400            continue;
401        }
402
403        for dep in &task.dependencies {
404            // Check for invalid "0" reference
405            if dep == "0" || dep.ends_with(":0") {
406                results
407                    .invalid_zero_deps
408                    .push((tag.to_string(), task.id.clone()));
409                continue;
410            }
411
412            // Check for self-reference
413            if dep == &task.id || dep == &format!("{}:{}", tag, task.id) {
414                results.self_refs.push((tag.to_string(), task.id.clone()));
415                continue;
416            }
417
418            // Check if dependency exists
419            let exists = local_ids.contains(dep)
420                || all_task_ids.contains(dep)
421                || all_task_ids.contains(&format!("{}:{}", tag, dep));
422
423            if !exists {
424                results
425                    .missing_deps
426                    .push((tag.to_string(), task.id.clone(), dep.clone()));
427                continue;
428            }
429
430            // Check if dependency is cancelled
431            if let Some(dep_task) = phase.get_task(dep) {
432                if dep_task.status == TaskStatus::Cancelled {
433                    results
434                        .cancelled_deps
435                        .push((tag.to_string(), task.id.clone(), dep.clone()));
436                }
437            }
438        }
439    }
440}
441
442fn print_dep_results(results: &DepCheckResults) {
443    if !results.has_issues() {
444        println!("{}", "✓ No dependency issues found!".green().bold());
445        return;
446    }
447
448    // Invalid zero references
449    if !results.invalid_zero_deps.is_empty() {
450        println!("{}", "Invalid Task Zero References".red().bold());
451        println!("{}", "-".repeat(40).red());
452        for (tag, task_id) in &results.invalid_zero_deps {
453            println!(
454                "  {} Task {} references invalid task \"0\"",
455                "✗".red(),
456                format!("{}:{}", tag, task_id).cyan()
457            );
458            println!(
459                "    {}",
460                "→ Task indices start at 1. Remove or update this dependency.".dimmed()
461            );
462        }
463        println!();
464    }
465
466    // Missing dependencies
467    if !results.missing_deps.is_empty() {
468        println!("{}", "Missing Dependencies".red().bold());
469        println!("{}", "-".repeat(40).red());
470        for (tag, task_id, dep) in &results.missing_deps {
471            println!(
472                "  {} Task {} depends on non-existent task {}",
473                "✗".red(),
474                format!("{}:{}", tag, task_id).cyan(),
475                dep.yellow()
476            );
477            println!(
478                "    {}",
479                format!("→ Remove dependency or create task {}", dep).dimmed()
480            );
481        }
482        println!();
483    }
484
485    // Self-references
486    if !results.self_refs.is_empty() {
487        println!("{}", "Self-Referencing Dependencies".red().bold());
488        println!("{}", "-".repeat(40).red());
489        for (tag, task_id) in &results.self_refs {
490            println!(
491                "  {} Task {} depends on itself",
492                "✗".red(),
493                format!("{}:{}", tag, task_id).cyan()
494            );
495            println!("    {}", "→ Remove self-referencing dependency.".dimmed());
496        }
497        println!();
498    }
499
500    // Cancelled dependencies
501    if !results.cancelled_deps.is_empty() {
502        println!("{}", "Dependencies on Cancelled Tasks".yellow().bold());
503        println!("{}", "-".repeat(40).yellow());
504        for (tag, task_id, dep) in &results.cancelled_deps {
505            println!(
506                "  {} Task {} depends on cancelled task {}",
507                "⚠".yellow(),
508                format!("{}:{}", tag, task_id).cyan(),
509                dep.yellow()
510            );
511            println!(
512                "    {}",
513                format!("→ Remove dependency or un-cancel {}", dep).dimmed()
514            );
515        }
516        println!();
517    }
518
519    // Summary
520    println!("{}", "Dependency Summary".blue().bold());
521    println!("{}", "-".repeat(40).blue());
522    println!(
523        "  Total issues: {}",
524        results.issue_count().to_string().red()
525    );
526    println!();
527    println!("{}", "To fix issues:".blue());
528    println!("  - Edit .scud/<tag>.scg directly");
529    println!("  - Or run: scud reanalyze-deps --apply");
530}
531
532fn print_prd_results(validation: &PrdValidationResult) -> bool {
533    // Coverage score with color coding
534    let score_color = if validation.coverage_score >= 90 {
535        validation.coverage_score.to_string().green()
536    } else if validation.coverage_score >= 70 {
537        validation.coverage_score.to_string().yellow()
538    } else {
539        validation.coverage_score.to_string().red()
540    };
541
542    println!(
543        "{} {}%",
544        "Coverage Score:".blue().bold(),
545        score_color.bold()
546    );
547    println!();
548
549    let has_issues = !validation.missing_requirements.is_empty()
550        || !validation.incomplete_coverage.is_empty()
551        || !validation.misaligned_tasks.is_empty();
552
553    if !validation.missing_requirements.is_empty() {
554        println!("{}", "Missing Requirements".red().bold());
555        println!("{}", "-".repeat(40).red());
556        for req in &validation.missing_requirements {
557            println!("  {} {}", "✗".red(), req.requirement.white());
558            println!("    {} {}", "Section:".dimmed(), req.prd_section.dimmed());
559            println!(
560                "    {} {}",
561                "Suggested task:".cyan(),
562                req.suggested_task.cyan()
563            );
564        }
565        println!();
566    }
567
568    if !validation.incomplete_coverage.is_empty() {
569        println!("{}", "Incomplete Coverage".yellow().bold());
570        println!("{}", "-".repeat(40).yellow());
571        for cov in &validation.incomplete_coverage {
572            println!("  {} {}", "⚠".yellow(), cov.requirement.white());
573            println!(
574                "    {} {}",
575                "Covered by:".dimmed(),
576                cov.existing_tasks.join(", ").dimmed()
577            );
578            println!("    {} {}", "Gap:".cyan(), cov.gap.cyan());
579        }
580        println!();
581    }
582
583    if !validation.misaligned_tasks.is_empty() {
584        println!("{}", "Misaligned Tasks".red().bold());
585        println!("{}", "-".repeat(40).red());
586        for task in &validation.misaligned_tasks {
587            println!("  {} Task {}", "✗".red(), task.task_id.cyan());
588            println!("    {} {}", "Issue:".dimmed(), task.issue.white());
589            println!("    {} {}", "Fix:".green(), task.suggestion.green());
590        }
591        println!();
592    }
593
594    if !validation.extra_tasks.is_empty() {
595        println!("{}", "Extra Tasks (beyond PRD scope)".blue().bold());
596        println!("{}", "-".repeat(40).blue());
597        for task in &validation.extra_tasks {
598            println!("  {} Task {}", "ℹ".blue(), task.task_id.cyan());
599            println!("    {}", task.note.dimmed());
600        }
601        println!();
602    }
603
604    if !validation.dependency_suggestions.is_empty() {
605        println!(
606            "{}",
607            "Suggested Dependencies (from PRD context)".cyan().bold()
608        );
609        println!("{}", "-".repeat(40).cyan());
610        for dep in &validation.dependency_suggestions {
611            println!(
612                "  {} Task {} should depend on {}",
613                "→".cyan(),
614                dep.task_id.cyan(),
615                dep.should_depend_on.join(", ").yellow()
616            );
617            println!("    {}", dep.reasoning.dimmed());
618        }
619        println!();
620    }
621
622    // Summary
623    println!("{}", "PRD Validation Summary".blue().bold());
624    println!("{}", "-".repeat(40).blue());
625    println!("  {}", validation.summary);
626    println!();
627
628    if !has_issues && validation.coverage_score >= 90 {
629        println!("{}", "✓ Tasks adequately cover the PRD!".green().bold());
630    } else if has_issues {
631        println!(
632            "{}",
633            "✗ PRD coverage issues found. Consider updating tasks.".red()
634        );
635    }
636
637    has_issues || validation.coverage_score < 70
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use crate::models::Task;
644
645    #[test]
646    fn test_results_has_issues() {
647        let mut results = DepCheckResults::default();
648        assert!(!results.has_issues());
649
650        results
651            .missing_deps
652            .push(("test".to_string(), "1".to_string(), "99".to_string()));
653        assert!(results.has_issues());
654    }
655
656    #[test]
657    fn test_detect_invalid_zero() {
658        let mut phase = Phase::new("test".to_string());
659        let mut task = Task::new("1".to_string(), "Test".to_string(), "".to_string());
660        task.dependencies = vec!["0".to_string()];
661        phase.add_task(task);
662
663        let all_ids: HashSet<String> = ["1".to_string()].into_iter().collect();
664        let mut results = DepCheckResults::default();
665
666        validate_phase("test", &phase, &all_ids, &mut results);
667
668        assert_eq!(results.invalid_zero_deps.len(), 1);
669        assert_eq!(
670            results.invalid_zero_deps[0],
671            ("test".to_string(), "1".to_string())
672        );
673    }
674
675    #[test]
676    fn test_detect_missing_dep() {
677        let mut phase = Phase::new("test".to_string());
678        let mut task = Task::new("1".to_string(), "Test".to_string(), "".to_string());
679        task.dependencies = vec!["99".to_string()];
680        phase.add_task(task);
681
682        let all_ids: HashSet<String> = ["1".to_string()].into_iter().collect();
683        let mut results = DepCheckResults::default();
684
685        validate_phase("test", &phase, &all_ids, &mut results);
686
687        assert_eq!(results.missing_deps.len(), 1);
688    }
689
690    #[test]
691    fn test_valid_deps_no_issues() {
692        let mut phase = Phase::new("test".to_string());
693
694        let task1 = Task::new("1".to_string(), "First".to_string(), "".to_string());
695        let mut task2 = Task::new("2".to_string(), "Second".to_string(), "".to_string());
696        task2.dependencies = vec!["1".to_string()];
697
698        phase.add_task(task1);
699        phase.add_task(task2);
700
701        let all_ids: HashSet<String> = ["1".to_string(), "2".to_string()].into_iter().collect();
702        let mut results = DepCheckResults::default();
703
704        validate_phase("test", &phase, &all_ids, &mut results);
705
706        assert!(!results.has_issues());
707    }
708}