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 #[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#[derive(Debug, Deserialize)]
100pub struct PrdFix {
101 pub action: String, 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 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 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 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 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 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_dep_results(&results);
181
182 let mut has_prd_issues = false;
183
184 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 let prd_content = storage.read_file(prd_path)?;
194
195 let tasks_json = build_tasks_json(&all_phases, &phases_to_check);
197
198 let client = match project_root {
200 Some(root) => LLMClient::new_with_project_root(root)?,
201 None => LLMClient::new()?,
202 };
203
204 let model_info = client.smart_model_info(model);
206 println!("{} {}", "Using".blue(), model_info.to_string().cyan());
207 println!();
208
209 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 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 has_prd_issues = print_prd_results(&validation);
227
228 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 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 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 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; } 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
387fn 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 if matches!(task.status, TaskStatus::Done | TaskStatus::Cancelled) {
435 continue;
436 }
437
438 for dep in &task.dependencies {
439 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 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 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 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 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 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 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 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 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 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 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}