scud/commands/
check_deps.rs

1use anyhow::Result;
2use colored::Colorize;
3use indicatif::{ProgressBar, ProgressStyle};
4use serde::Deserialize;
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)]
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)]
55pub struct MissingRequirement {
56    pub requirement: String,
57    pub prd_section: String,
58    pub suggested_task: String,
59}
60
61#[derive(Debug, Deserialize)]
62pub struct IncompleteCoverage {
63    pub requirement: String,
64    pub existing_tasks: Vec<String>,
65    pub gap: String,
66}
67
68#[derive(Debug, Deserialize)]
69pub struct MisalignedTask {
70    pub task_id: String,
71    pub issue: String,
72    pub suggestion: String,
73}
74
75#[derive(Debug, Deserialize)]
76pub struct ExtraTask {
77    pub task_id: String,
78    pub note: String,
79}
80
81#[derive(Debug, Deserialize)]
82pub struct DependencySuggestion {
83    pub task_id: String,
84    pub should_depend_on: Vec<String>,
85    pub reasoning: String,
86}
87
88pub async fn run(
89    project_root: Option<PathBuf>,
90    tag: Option<&str>,
91    all_tags: bool,
92    prd_file: Option<&Path>,
93    model: Option<&str>,
94) -> Result<()> {
95    let storage = Storage::new(project_root.clone());
96
97    if !storage.is_initialized() {
98        anyhow::bail!("SCUD not initialized. Run: scud init");
99    }
100
101    let all_phases = storage.load_tasks()?;
102
103    if all_phases.is_empty() {
104        println!("{}", "No tasks found.".yellow());
105        return Ok(());
106    }
107
108    // Determine which phases to check
109    let phases_to_check: Vec<String> = match tag {
110        Some(t) if !all_tags => {
111            if !all_phases.contains_key(t) {
112                anyhow::bail!("Tag '{}' not found", t);
113            }
114            vec![t.to_string()]
115        }
116        _ => all_phases.keys().cloned().collect(),
117    };
118
119    println!(
120        "{} Checking dependencies across {} phase(s)...\n",
121        "Validating".blue(),
122        phases_to_check.len()
123    );
124
125    let mut results = DepCheckResults::default();
126
127    // Build global task ID set for cross-phase validation
128    let all_task_ids: HashSet<String> = all_phases
129        .iter()
130        .flat_map(|(tag, phase)| {
131            phase.tasks.iter().flat_map(move |t| {
132                let mut ids = vec![t.id.clone(), format!("{}:{}", tag, t.id)];
133                // Also add subtask IDs if present
134                for subtask_id in &t.subtasks {
135                    ids.push(subtask_id.clone());
136                    ids.push(format!("{}:{}", tag, subtask_id));
137                }
138                ids
139            })
140        })
141        .collect();
142
143    // Validate each phase
144    for tag in &phases_to_check {
145        if let Some(phase) = all_phases.get(tag) {
146            validate_phase(tag, phase, &all_task_ids, &mut results);
147        }
148    }
149
150    // Print dependency results
151    print_dep_results(&results);
152
153    let mut has_prd_issues = false;
154
155    // PRD validation if file provided
156    if let Some(prd_path) = prd_file {
157        println!();
158        println!("{}", "━".repeat(50).blue());
159        println!("{}", "PRD Coverage Validation".blue().bold());
160        println!("{}", "━".repeat(50).blue());
161        println!();
162
163        // Read PRD file
164        let prd_content = storage.read_file(prd_path)?;
165
166        // Build tasks JSON for LLM
167        let tasks_json = build_tasks_json(&all_phases, &phases_to_check);
168
169        // Create LLM client
170        let client = match project_root {
171            Some(root) => LLMClient::new_with_project_root(root)?,
172            None => LLMClient::new()?,
173        };
174
175        // Show progress
176        let spinner = ProgressBar::new_spinner();
177        spinner.set_style(
178            ProgressStyle::default_spinner()
179                .template("{spinner:.blue} {msg}")
180                .unwrap(),
181        );
182        spinner.set_message("Validating tasks against PRD with AI...");
183        spinner.enable_steady_tick(std::time::Duration::from_millis(100));
184
185        // Call LLM to validate (use smart model for validation tasks)
186        let prompt = Prompts::validate_tasks_against_prd(&prd_content, &tasks_json);
187        let validation: PrdValidationResult = client.complete_json_smart(&prompt, model).await?;
188
189        spinner.finish_and_clear();
190
191        // Print PRD validation results
192        has_prd_issues = print_prd_results(&validation);
193    }
194
195    if results.has_issues() || has_prd_issues {
196        std::process::exit(1);
197    }
198
199    Ok(())
200}
201
202fn build_tasks_json(
203    all_phases: &std::collections::HashMap<String, Phase>,
204    phases_to_check: &[String],
205) -> String {
206    let mut tasks_list = Vec::new();
207
208    for tag in phases_to_check {
209        if let Some(phase) = all_phases.get(tag) {
210            for task in &phase.tasks {
211                tasks_list.push(serde_json::json!({
212                    "id": format!("{}:{}", tag, task.id),
213                    "title": task.title,
214                    "description": task.description,
215                    "status": format!("{:?}", task.status),
216                    "priority": format!("{:?}", task.priority),
217                    "complexity": task.complexity,
218                    "dependencies": task.dependencies,
219                }));
220            }
221        }
222    }
223
224    serde_json::to_string_pretty(&tasks_list).unwrap_or_else(|_| "[]".to_string())
225}
226
227pub fn validate_phase(
228    tag: &str,
229    phase: &Phase,
230    all_task_ids: &HashSet<String>,
231    results: &mut DepCheckResults,
232) {
233    let local_ids: HashSet<_> = phase.tasks.iter().map(|t| t.id.clone()).collect();
234
235    for task in &phase.tasks {
236        // Skip completed/cancelled tasks
237        if matches!(task.status, TaskStatus::Done | TaskStatus::Cancelled) {
238            continue;
239        }
240
241        for dep in &task.dependencies {
242            // Check for invalid "0" reference
243            if dep == "0" || dep.ends_with(":0") {
244                results.invalid_zero_deps.push((tag.to_string(), task.id.clone()));
245                continue;
246            }
247
248            // Check for self-reference
249            if dep == &task.id || dep == &format!("{}:{}", tag, task.id) {
250                results.self_refs.push((tag.to_string(), task.id.clone()));
251                continue;
252            }
253
254            // Check if dependency exists
255            let exists = local_ids.contains(dep)
256                || all_task_ids.contains(dep)
257                || all_task_ids.contains(&format!("{}:{}", tag, dep));
258
259            if !exists {
260                results.missing_deps.push((
261                    tag.to_string(),
262                    task.id.clone(),
263                    dep.clone(),
264                ));
265                continue;
266            }
267
268            // Check if dependency is cancelled
269            if let Some(dep_task) = phase.get_task(dep) {
270                if dep_task.status == TaskStatus::Cancelled {
271                    results.cancelled_deps.push((
272                        tag.to_string(),
273                        task.id.clone(),
274                        dep.clone(),
275                    ));
276                }
277            }
278        }
279    }
280}
281
282fn print_dep_results(results: &DepCheckResults) {
283    if !results.has_issues() {
284        println!("{}", "✓ No dependency issues found!".green().bold());
285        return;
286    }
287
288    // Invalid zero references
289    if !results.invalid_zero_deps.is_empty() {
290        println!("{}", "Invalid Task Zero References".red().bold());
291        println!("{}", "-".repeat(40).red());
292        for (tag, task_id) in &results.invalid_zero_deps {
293            println!(
294                "  {} Task {} references invalid task \"0\"",
295                "✗".red(),
296                format!("{}:{}", tag, task_id).cyan()
297            );
298            println!(
299                "    {}",
300                "→ Task indices start at 1. Remove or update this dependency.".dimmed()
301            );
302        }
303        println!();
304    }
305
306    // Missing dependencies
307    if !results.missing_deps.is_empty() {
308        println!("{}", "Missing Dependencies".red().bold());
309        println!("{}", "-".repeat(40).red());
310        for (tag, task_id, dep) in &results.missing_deps {
311            println!(
312                "  {} Task {} depends on non-existent task {}",
313                "✗".red(),
314                format!("{}:{}", tag, task_id).cyan(),
315                dep.yellow()
316            );
317            println!(
318                "    {}",
319                format!("→ Remove dependency or create task {}", dep).dimmed()
320            );
321        }
322        println!();
323    }
324
325    // Self-references
326    if !results.self_refs.is_empty() {
327        println!("{}", "Self-Referencing Dependencies".red().bold());
328        println!("{}", "-".repeat(40).red());
329        for (tag, task_id) in &results.self_refs {
330            println!(
331                "  {} Task {} depends on itself",
332                "✗".red(),
333                format!("{}:{}", tag, task_id).cyan()
334            );
335            println!("    {}", "→ Remove self-referencing dependency.".dimmed());
336        }
337        println!();
338    }
339
340    // Cancelled dependencies
341    if !results.cancelled_deps.is_empty() {
342        println!("{}", "Dependencies on Cancelled Tasks".yellow().bold());
343        println!("{}", "-".repeat(40).yellow());
344        for (tag, task_id, dep) in &results.cancelled_deps {
345            println!(
346                "  {} Task {} depends on cancelled task {}",
347                "⚠".yellow(),
348                format!("{}:{}", tag, task_id).cyan(),
349                dep.yellow()
350            );
351            println!(
352                "    {}",
353                format!("→ Remove dependency or un-cancel {}", dep).dimmed()
354            );
355        }
356        println!();
357    }
358
359    // Summary
360    println!("{}", "Dependency Summary".blue().bold());
361    println!("{}", "-".repeat(40).blue());
362    println!(
363        "  Total issues: {}",
364        results.issue_count().to_string().red()
365    );
366    println!();
367    println!("{}", "To fix issues:".blue());
368    println!("  - Edit .scud/<tag>.scg directly");
369    println!("  - Or run: scud reanalyze-deps --apply");
370}
371
372fn print_prd_results(validation: &PrdValidationResult) -> bool {
373    // Coverage score with color coding
374    let score_color = if validation.coverage_score >= 90 {
375        validation.coverage_score.to_string().green()
376    } else if validation.coverage_score >= 70 {
377        validation.coverage_score.to_string().yellow()
378    } else {
379        validation.coverage_score.to_string().red()
380    };
381
382    println!(
383        "{} {}%",
384        "Coverage Score:".blue().bold(),
385        score_color.bold()
386    );
387    println!();
388
389    let has_issues = !validation.missing_requirements.is_empty()
390        || !validation.incomplete_coverage.is_empty()
391        || !validation.misaligned_tasks.is_empty();
392
393    if !validation.missing_requirements.is_empty() {
394        println!("{}", "Missing Requirements".red().bold());
395        println!("{}", "-".repeat(40).red());
396        for req in &validation.missing_requirements {
397            println!("  {} {}", "✗".red(), req.requirement.white());
398            println!("    {} {}", "Section:".dimmed(), req.prd_section.dimmed());
399            println!(
400                "    {} {}",
401                "Suggested task:".cyan(),
402                req.suggested_task.cyan()
403            );
404        }
405        println!();
406    }
407
408    if !validation.incomplete_coverage.is_empty() {
409        println!("{}", "Incomplete Coverage".yellow().bold());
410        println!("{}", "-".repeat(40).yellow());
411        for cov in &validation.incomplete_coverage {
412            println!("  {} {}", "⚠".yellow(), cov.requirement.white());
413            println!(
414                "    {} {}",
415                "Covered by:".dimmed(),
416                cov.existing_tasks.join(", ").dimmed()
417            );
418            println!("    {} {}", "Gap:".cyan(), cov.gap.cyan());
419        }
420        println!();
421    }
422
423    if !validation.misaligned_tasks.is_empty() {
424        println!("{}", "Misaligned Tasks".red().bold());
425        println!("{}", "-".repeat(40).red());
426        for task in &validation.misaligned_tasks {
427            println!("  {} Task {}", "✗".red(), task.task_id.cyan());
428            println!("    {} {}", "Issue:".dimmed(), task.issue.white());
429            println!("    {} {}", "Fix:".green(), task.suggestion.green());
430        }
431        println!();
432    }
433
434    if !validation.extra_tasks.is_empty() {
435        println!("{}", "Extra Tasks (beyond PRD scope)".blue().bold());
436        println!("{}", "-".repeat(40).blue());
437        for task in &validation.extra_tasks {
438            println!("  {} Task {}", "ℹ".blue(), task.task_id.cyan());
439            println!("    {}", task.note.dimmed());
440        }
441        println!();
442    }
443
444    if !validation.dependency_suggestions.is_empty() {
445        println!("{}", "Suggested Dependencies (from PRD context)".cyan().bold());
446        println!("{}", "-".repeat(40).cyan());
447        for dep in &validation.dependency_suggestions {
448            println!(
449                "  {} Task {} should depend on {}",
450                "→".cyan(),
451                dep.task_id.cyan(),
452                dep.should_depend_on.join(", ").yellow()
453            );
454            println!("    {}", dep.reasoning.dimmed());
455        }
456        println!();
457    }
458
459    // Summary
460    println!("{}", "PRD Validation Summary".blue().bold());
461    println!("{}", "-".repeat(40).blue());
462    println!("  {}", validation.summary);
463    println!();
464
465    if !has_issues && validation.coverage_score >= 90 {
466        println!("{}", "✓ Tasks adequately cover the PRD!".green().bold());
467    } else if has_issues {
468        println!(
469            "{}",
470            "✗ PRD coverage issues found. Consider updating tasks.".red()
471        );
472    }
473
474    has_issues || validation.coverage_score < 70
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::models::Task;
481
482    #[test]
483    fn test_results_has_issues() {
484        let mut results = DepCheckResults::default();
485        assert!(!results.has_issues());
486
487        results.missing_deps.push(("test".to_string(), "1".to_string(), "99".to_string()));
488        assert!(results.has_issues());
489    }
490
491    #[test]
492    fn test_detect_invalid_zero() {
493        let mut phase = Phase::new("test".to_string());
494        let mut task = Task::new("1".to_string(), "Test".to_string(), "".to_string());
495        task.dependencies = vec!["0".to_string()];
496        phase.add_task(task);
497
498        let all_ids: HashSet<String> = ["1".to_string()].into_iter().collect();
499        let mut results = DepCheckResults::default();
500
501        validate_phase("test", &phase, &all_ids, &mut results);
502
503        assert_eq!(results.invalid_zero_deps.len(), 1);
504        assert_eq!(results.invalid_zero_deps[0], ("test".to_string(), "1".to_string()));
505    }
506
507    #[test]
508    fn test_detect_missing_dep() {
509        let mut phase = Phase::new("test".to_string());
510        let mut task = Task::new("1".to_string(), "Test".to_string(), "".to_string());
511        task.dependencies = vec!["99".to_string()];
512        phase.add_task(task);
513
514        let all_ids: HashSet<String> = ["1".to_string()].into_iter().collect();
515        let mut results = DepCheckResults::default();
516
517        validate_phase("test", &phase, &all_ids, &mut results);
518
519        assert_eq!(results.missing_deps.len(), 1);
520    }
521
522    #[test]
523    fn test_valid_deps_no_issues() {
524        let mut phase = Phase::new("test".to_string());
525
526        let task1 = Task::new("1".to_string(), "First".to_string(), "".to_string());
527        let mut task2 = Task::new("2".to_string(), "Second".to_string(), "".to_string());
528        task2.dependencies = vec!["1".to_string()];
529
530        phase.add_task(task1);
531        phase.add_task(task2);
532
533        let all_ids: HashSet<String> = ["1".to_string(), "2".to_string()].into_iter().collect();
534        let mut results = DepCheckResults::default();
535
536        validate_phase("test", &phase, &all_ids, &mut results);
537
538        assert!(!results.has_issues());
539    }
540}