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#[derive(Debug, Default)]
14pub struct DepCheckResults {
15 pub missing_deps: Vec<(String, String, String)>, pub invalid_zero_deps: Vec<(String, String)>, pub self_refs: Vec<(String, String)>, pub cancelled_deps: Vec<(String, String, String)>, }
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#[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#[derive(Debug, Deserialize)]
90pub struct PrdFix {
91 pub action: String, 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 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 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 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 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 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_dep_results(&results);
170
171 let mut has_prd_issues = false;
172
173 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 let prd_content = storage.read_file(prd_path)?;
183
184 let tasks_json = build_tasks_json(&all_phases, &phases_to_check);
186
187 let client = match project_root {
189 Some(root) => LLMClient::new_with_project_root(root)?,
190 None => LLMClient::new()?,
191 };
192
193 let model_info = client.smart_model_info(model);
195 println!("{} {}", "Using".blue(), model_info.to_string().cyan());
196 println!();
197
198 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 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 has_prd_issues = print_prd_results(&validation);
216
217 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 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 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 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; } 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
353fn 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 if matches!(task.status, TaskStatus::Done | TaskStatus::Cancelled) {
400 continue;
401 }
402
403 for dep in &task.dependencies {
404 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 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 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 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 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 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 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 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 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 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 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}