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