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#[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)]
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 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 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 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 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_dep_results(&results);
152
153 let mut has_prd_issues = false;
154
155 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 let prd_content = storage.read_file(prd_path)?;
165
166 let tasks_json = build_tasks_json(&all_phases, &phases_to_check);
168
169 let client = match project_root {
171 Some(root) => LLMClient::new_with_project_root(root)?,
172 None => LLMClient::new()?,
173 };
174
175 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 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 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 if matches!(task.status, TaskStatus::Done | TaskStatus::Cancelled) {
238 continue;
239 }
240
241 for dep in &task.dependencies {
242 if dep == "0" || dep.ends_with(":0") {
244 results.invalid_zero_deps.push((tag.to_string(), task.id.clone()));
245 continue;
246 }
247
248 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 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 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 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 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 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 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 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 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 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}