Skip to main content

scud/commands/
doctor.rs

1use anyhow::Result;
2use colored::Colorize;
3use std::collections::HashSet;
4use std::path::PathBuf;
5
6use crate::models::task::TaskStatus;
7use crate::storage::Storage;
8
9/// Diagnostic issue severity
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Severity {
12    Warning,
13    Error,
14    Critical,
15}
16
17impl Severity {
18    pub fn as_str(&self) -> &'static str {
19        match self {
20            Severity::Warning => "WARNING",
21            Severity::Error => "ERROR",
22            Severity::Critical => "CRITICAL",
23        }
24    }
25}
26
27/// A diagnostic issue found by the doctor command
28#[derive(Debug, Clone)]
29pub struct DiagnosticIssue {
30    pub severity: Severity,
31    pub epic_tag: String,
32    pub task_id: Option<String>,
33    pub message: String,
34    pub suggestion: String,
35}
36
37/// Results from running diagnostics
38#[derive(Debug, Default)]
39pub struct DiagnosticResults {
40    pub issues: Vec<DiagnosticIssue>,
41    pub blocked_by_cancelled: Vec<(String, String, String)>, // (epic, task_id, blocked_dep)
42    pub blocked_by_missing: Vec<(String, String, String)>,   // (epic, task_id, missing_dep)
43    pub orphan_in_progress: Vec<(String, String)>, // (epic, task_id) - in-progress >threshold without activity
44    pub missing_active_epic: bool,
45    pub corrupt_files: Vec<String>,
46}
47
48impl DiagnosticResults {
49    pub fn has_issues(&self) -> bool {
50        !self.issues.is_empty()
51            || !self.blocked_by_cancelled.is_empty()
52            || !self.blocked_by_missing.is_empty()
53            || !self.orphan_in_progress.is_empty()
54            || self.missing_active_epic
55            || !self.corrupt_files.is_empty()
56    }
57
58    pub fn critical_count(&self) -> usize {
59        self.issues
60            .iter()
61            .filter(|i| i.severity == Severity::Critical)
62            .count()
63            + self.corrupt_files.len()
64    }
65
66    pub fn error_count(&self) -> usize {
67        self.issues
68            .iter()
69            .filter(|i| i.severity == Severity::Error)
70            .count()
71            + self.blocked_by_cancelled.len()
72            + self.blocked_by_missing.len()
73    }
74
75    pub fn warning_count(&self) -> usize {
76        self.issues
77            .iter()
78            .filter(|i| i.severity == Severity::Warning)
79            .count()
80            + self.orphan_in_progress.len()
81            + if self.missing_active_epic { 1 } else { 0 }
82    }
83}
84
85pub fn run(
86    project_root: Option<PathBuf>,
87    tag: Option<&str>,
88    stale_hours: f64,
89    fix: bool,
90) -> Result<()> {
91    run_workflow_diagnostics(project_root, tag, stale_hours, fix)
92}
93
94fn run_workflow_diagnostics(
95    project_root: Option<PathBuf>,
96    tag: Option<&str>,
97    stale_hours: f64,
98    fix: bool,
99) -> Result<()> {
100    println!(
101        "{}",
102        "[EXPERIMENTAL] SCUD Doctor - Workflow Diagnostics"
103            .blue()
104            .bold()
105    );
106    println!("{}", "=".repeat(60).blue());
107    println!();
108
109    let storage = Storage::new(project_root);
110
111    // Check if storage files exist and are readable
112    let tasks_result = storage.load_tasks();
113
114    let mut results = DiagnosticResults::default();
115
116    // Check for corrupt/missing files
117    if let Err(ref e) = tasks_result {
118        results.corrupt_files.push(format!("tasks file: {}", e));
119    }
120
121    // Check active epic
122    match storage.get_active_group() {
123        Ok(Some(_)) => {}
124        Ok(None) => {
125            results.missing_active_epic = true;
126        }
127        Err(_) => {
128            results.missing_active_epic = true;
129        }
130    }
131
132    // If we couldn't load tasks, show what we found and exit
133    if !results.corrupt_files.is_empty() {
134        print_results(&results, fix);
135        return Ok(());
136    }
137
138    let mut all_tasks = tasks_result?;
139
140    // Filter to specific tag if provided
141    let epic_tags: Vec<String> = if let Some(t) = tag {
142        if all_tasks.contains_key(t) {
143            vec![t.to_string()]
144        } else {
145            anyhow::bail!("Phase '{}' not found", t);
146        }
147    } else {
148        all_tasks.keys().cloned().collect()
149    };
150
151    // Run diagnostics on each epic
152    for epic_tag in &epic_tags {
153        let epic = match all_tasks.get(epic_tag) {
154            Some(e) => e,
155            None => continue,
156        };
157
158        // Build set of all task IDs for dependency checking
159        let all_task_ids: HashSet<_> = epic.tasks.iter().map(|t| t.id.clone()).collect();
160
161        for task in &epic.tasks {
162            // Check for orphan in-progress tasks (in-progress for too long without activity)
163            if task.status == TaskStatus::InProgress {
164                if let Some(ref updated_at) = task.updated_at {
165                    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(updated_at) {
166                        let hours =
167                            (chrono::Utc::now().signed_duration_since(dt)).num_hours() as f64;
168                        if hours > stale_hours {
169                            results
170                                .orphan_in_progress
171                                .push((epic_tag.clone(), task.id.clone()));
172                        }
173                    }
174                }
175            }
176
177            // Check for dependencies on cancelled/blocked tasks
178            if task.status == TaskStatus::Pending {
179                for dep_id in &task.dependencies {
180                    // Check if dependency exists
181                    if !all_task_ids.contains(dep_id) {
182                        results.blocked_by_missing.push((
183                            epic_tag.clone(),
184                            task.id.clone(),
185                            dep_id.clone(),
186                        ));
187                        continue;
188                    }
189
190                    // Check if dependency is cancelled or blocked
191                    if let Some(dep_task) = epic.get_task(dep_id) {
192                        match dep_task.status {
193                            TaskStatus::Cancelled => {
194                                results.blocked_by_cancelled.push((
195                                    epic_tag.clone(),
196                                    task.id.clone(),
197                                    dep_id.clone(),
198                                ));
199                            }
200                            TaskStatus::Blocked => {
201                                results.issues.push(DiagnosticIssue {
202                                    severity: Severity::Warning,
203                                    epic_tag: epic_tag.clone(),
204                                    task_id: Some(task.id.clone()),
205                                    message: format!(
206                                        "Task {} depends on blocked task {}",
207                                        task.id, dep_id
208                                    ),
209                                    suggestion: format!(
210                                        "Resolve blocker for {} or remove dependency",
211                                        dep_id
212                                    ),
213                                });
214                            }
215                            TaskStatus::Deferred => {
216                                results.issues.push(DiagnosticIssue {
217                                    severity: Severity::Warning,
218                                    epic_tag: epic_tag.clone(),
219                                    task_id: Some(task.id.clone()),
220                                    message: format!(
221                                        "Task {} depends on deferred task {}",
222                                        task.id, dep_id
223                                    ),
224                                    suggestion: format!("Un-defer {} or update dependency", dep_id),
225                                });
226                            }
227                            _ => {}
228                        }
229                    }
230                }
231            }
232        }
233    }
234
235    // Apply fixes if requested
236    if fix && results.has_issues() {
237        println!("{}", "Attempting auto-fixes...".yellow());
238        println!();
239
240        let mut fixed_count = 0;
241
242        // Fix orphan in-progress tasks (reset to pending)
243        for (epic_tag, task_id) in &results.orphan_in_progress {
244            if let Some(epic) = all_tasks.get_mut(epic_tag) {
245                if let Some(task) = epic.get_task_mut(task_id) {
246                    task.set_status(TaskStatus::Pending);
247                    println!(
248                        "{} Reset stale in-progress task to pending: {}",
249                        "✓".green(),
250                        task_id.cyan()
251                    );
252                    fixed_count += 1;
253                }
254            }
255        }
256
257        if fixed_count > 0 {
258            storage.save_tasks(&all_tasks)?;
259            println!();
260            println!("{} {} issue(s) fixed", "✓".green(), fixed_count);
261        } else {
262            println!(
263                "{}",
264                "No auto-fixable issues found. Manual intervention required.".yellow()
265            );
266        }
267        println!();
268    }
269
270    print_results(&results, fix);
271
272    Ok(())
273}
274
275fn print_results(results: &DiagnosticResults, fix_attempted: bool) {
276    if !results.has_issues() {
277        println!(
278            "{}",
279            "✓ No issues found! Workflow is healthy.".green().bold()
280        );
281        return;
282    }
283
284    // Print critical issues (corrupt files)
285    if !results.corrupt_files.is_empty() {
286        println!("{}", "CRITICAL: File Issues".red().bold());
287        println!("{}", "-".repeat(40).red());
288        for file_issue in &results.corrupt_files {
289            println!("  {} {}", "✗".red(), file_issue);
290        }
291        println!();
292        print_recovery_instructions();
293        return;
294    }
295
296    // Print blocked by cancelled
297    if !results.blocked_by_cancelled.is_empty() {
298        println!("{}", "Tasks Blocked by Cancelled Dependencies".red().bold());
299        println!("{}", "-".repeat(40).red());
300        for (epic, task_id, dep_id) in &results.blocked_by_cancelled {
301            println!(
302                "  {} {} depends on cancelled task {}",
303                "✗".red(),
304                task_id.cyan(),
305                dep_id.yellow()
306            );
307            println!(
308                "    {}",
309                format!(
310                    "→ Remove dependency or un-cancel {} (in epic {})",
311                    dep_id, epic
312                )
313                .dimmed()
314            );
315        }
316        println!();
317    }
318
319    // Print blocked by missing
320    if !results.blocked_by_missing.is_empty() {
321        println!("{}", "Tasks with Missing Dependencies".red().bold());
322        println!("{}", "-".repeat(40).red());
323        for (epic, task_id, dep_id) in &results.blocked_by_missing {
324            println!(
325                "  {} {} depends on non-existent task {}",
326                "✗".red(),
327                task_id.cyan(),
328                dep_id.yellow()
329            );
330            println!(
331                "    {}",
332                format!("→ Remove dependency from {} (in epic {})", task_id, epic).dimmed()
333            );
334        }
335        println!();
336    }
337
338    // Print orphan in-progress
339    if !results.orphan_in_progress.is_empty() {
340        println!(
341            "{}",
342            "Stale In-Progress Tasks (no activity)".yellow().bold()
343        );
344        println!("{}", "-".repeat(40).yellow());
345        for (epic, task_id) in &results.orphan_in_progress {
346            println!(
347                "  {} {} in {} - in-progress but no recent activity",
348                "⚠".yellow(),
349                task_id.cyan(),
350                epic.dimmed()
351            );
352            if !fix_attempted {
353                println!(
354                    "    {}",
355                    format!(
356                        "→ scud set-status {} pending -t {}  # or done if complete",
357                        task_id, epic
358                    )
359                    .dimmed()
360                );
361            }
362        }
363        println!();
364    }
365
366    // Print missing active epic
367    if results.missing_active_epic {
368        println!("{}", "No Active Phase Set".yellow().bold());
369        println!("{}", "-".repeat(40).yellow());
370        println!("  {} No active epic/tag is set", "⚠".yellow());
371        println!(
372            "    {}",
373            "→ scud tags <epic-name>  # to set active epic".dimmed()
374        );
375        println!();
376    }
377
378    // Print other issues
379    for issue in &results.issues {
380        let (icon, color_fn): (&str, fn(&str) -> colored::ColoredString) = match issue.severity {
381            Severity::Critical => ("✗", |s: &str| s.red()),
382            Severity::Error => ("✗", |s: &str| s.red()),
383            Severity::Warning => ("⚠", |s: &str| s.yellow()),
384        };
385
386        println!(
387            "  {} [{}] {}",
388            color_fn(icon),
389            issue.severity.as_str(),
390            issue.message
391        );
392        if let Some(ref task_id) = issue.task_id {
393            println!(
394                "    Task: {} in {}",
395                task_id.cyan(),
396                issue.epic_tag.dimmed()
397            );
398        }
399        println!("    {}", format!("→ {}", issue.suggestion).dimmed());
400    }
401
402    // Summary
403    println!();
404    println!("{}", "Summary".blue().bold());
405    println!("{}", "-".repeat(40).blue());
406    println!(
407        "  Critical: {}  Errors: {}  Warnings: {}",
408        results.critical_count().to_string().red(),
409        results.error_count().to_string().yellow(),
410        results.warning_count().to_string().blue()
411    );
412
413    if !fix_attempted && !results.orphan_in_progress.is_empty() {
414        println!();
415        println!("{}", "To auto-fix recoverable issues, run:".blue());
416        println!("  scud doctor --fix");
417    }
418}
419
420fn print_recovery_instructions() {
421    println!();
422    println!("{}", "=".repeat(60).red());
423    println!("{}", "RECOVERY INSTRUCTIONS".red().bold());
424    println!("{}", "=".repeat(60).red());
425    println!();
426    println!("The task storage appears corrupted or missing. To recover:");
427    println!();
428    println!("1. Check if .scud/ directory exists:");
429    println!("   {}", "ls -la .scud/".cyan());
430    println!();
431    println!("2. If missing, initialize SCUD:");
432    println!("   {}", "scud init".cyan());
433    println!();
434    println!("3. If corrupted, check for backups:");
435    println!("   {}", "ls -la .scud/tasks/*.bak".cyan());
436    println!();
437    println!("4. If no backups, you may need to recreate tasks:");
438    println!(
439        "   {}",
440        "scud parse-prd <prd-file> --tag <epic-name>".cyan()
441    );
442    println!();
443    println!("5. For manual recovery, task files are located at:");
444    println!("   {}", ".scud/tasks/tasks.scg (or tasks.json)".dimmed());
445    println!("   {}", ".scud/active-tag".dimmed());
446    println!();
447    println!(
448        "{}",
449        "If issues persist, consider consulting a high-context agent".yellow()
450    );
451    println!(
452        "{}",
453        "with full codebase access to inspect and repair the files.".yellow()
454    );
455}
456
457pub fn scan_ext(project_root: Option<PathBuf>) -> Result<()> {
458    use crate::commands::spawn::terminal::{find_harness_binary, Harness};
459    use crate::extensions::loader::ExtensionManifest;
460    use std::os::unix::fs::PermissionsExt;
461
462    println!(
463        "{}",
464        "[EXPERIMENTAL] SCUD Doctor - Extension Scanner"
465            .blue()
466            .bold()
467    );
468    println!("{}", "=".repeat(60).blue());
469    println!();
470
471    let project_root = project_root.unwrap_or_else(|| std::env::current_dir().unwrap());
472    let agents_dir = project_root.join(".scud").join("agents");
473
474    println!("Scanning extensions in: {}", agents_dir.display());
475    println!();
476
477    let mut issues = Vec::new();
478    let mut scanned_count = 0;
479
480    // Check if agents directory exists
481    if !agents_dir.exists() {
482        println!(
483            "{}",
484            "No extensions directory found (.scud/agents/)".yellow()
485        );
486        println!("Extensions are automatically created when agents are configured.");
487        return Ok(());
488    }
489
490    // Find all TOML files in the agents directory
491    let entries = match std::fs::read_dir(&agents_dir) {
492        Ok(entries) => entries,
493        Err(e) => {
494            issues.push(DiagnosticIssue {
495                severity: Severity::Critical,
496                epic_tag: "extensions".to_string(),
497                task_id: None,
498                message: format!("Cannot read extensions directory: {}", e),
499                suggestion: r#"Check permissions on .scud/agents/ directory"#.to_string(),
500            });
501            return print_scan_results(&issues, scanned_count);
502        }
503    };
504
505    for entry in entries {
506        let entry = match entry {
507            Ok(e) => e,
508            Err(e) => {
509                issues.push(DiagnosticIssue {
510                    severity: Severity::Error,
511                    epic_tag: "extensions".to_string(),
512                    task_id: None,
513                    message: format!("Error reading directory entry: {}", e),
514                    suggestion: r#"Check directory permissions"#.to_string(),
515                });
516                continue;
517            }
518        };
519
520        let path = entry.path();
521        if !path.extension().map_or(false, |ext| ext == "toml") {
522            continue;
523        }
524
525        scanned_count += 1;
526        let filename = path.file_stem().unwrap_or_default().to_string_lossy();
527
528        println!("Checking extension: {}", filename.cyan());
529
530        // 1. Check manifest validity
531        let manifest = match ExtensionManifest::from_file(&path) {
532            Ok(m) => m,
533            Err(e) => {
534                issues.push(DiagnosticIssue {
535                    severity: Severity::Critical,
536                    epic_tag: "extensions".to_string(),
537                    task_id: Some(filename.to_string()),
538                    message: format!("Invalid manifest: {}", e),
539                    suggestion: format!("Fix TOML syntax in {}", path.display()),
540                });
541                continue;
542            }
543        };
544
545        // 2. Check file permissions
546        match std::fs::metadata(&path) {
547            Ok(metadata) => {
548                let permissions = metadata.permissions();
549                let mode = permissions.mode();
550
551                // Check if readable by owner
552                if mode & 0o400 == 0 {
553                    issues.push(DiagnosticIssue {
554                        severity: Severity::Error,
555                        epic_tag: "extensions".to_string(),
556                        task_id: Some(filename.to_string()),
557                        message: format!("Extension file not readable: {}", path.display()),
558                        suggestion: r#"Run: chmod +r <file>.toml"#.to_string(),
559                    });
560                }
561            }
562            Err(e) => {
563                issues.push(DiagnosticIssue {
564                    severity: Severity::Error,
565                    epic_tag: "extensions".to_string(),
566                    task_id: Some(filename.to_string()),
567                    message: format!("Cannot access extension file: {}", e),
568                    suggestion: r#"Check file permissions"#.to_string(),
569                });
570            }
571        }
572
573        // 3. Check required binaries (harnesses)
574        if let Some(config) = manifest.config.get("harness") {
575            if let Some(harness_str) = config.as_str() {
576                // Try to parse the harness
577                match Harness::parse(harness_str) {
578                    Ok(harness) => {
579                        // Try to find the harness binary
580                        match find_harness_binary(harness) {
581                            Ok(_) => {
582                                // Binary found, good
583                            }
584                            Err(_) => {
585                                issues.push(DiagnosticIssue {
586                                    severity: Severity::Critical,
587                                    epic_tag: "extensions".to_string(),
588                                    task_id: Some(filename.to_string()),
589                                    message: format!(
590                                        r#"Required harness '{}' not found in PATH"#,
591                                        harness_str
592                                    ),
593                                    suggestion: format!(r#"Install {} or check PATH"#, harness_str),
594                                });
595                            }
596                        }
597                    }
598                    Err(e) => {
599                        issues.push(DiagnosticIssue {
600                            severity: Severity::Error,
601                            epic_tag: "extensions".to_string(),
602                            task_id: Some(filename.to_string()),
603                            message: format!(r#"Invalid harness name '{}': {}"#, harness_str, e),
604                            suggestion: r#"Use 'claude' or 'opencode'"#.to_string(),
605                        });
606                    }
607                }
608            }
609        }
610
611        // 4. Check for required dependencies
612        for (dep_name, dep_version) in &manifest.dependencies {
613            // For now, just warn about dependencies (we don't have a registry to check against)
614            issues.push(DiagnosticIssue {
615                severity: Severity::Warning,
616                epic_tag: "extensions".to_string(),
617                task_id: Some(filename.to_string()),
618                message: format!(
619                    r#"Extension has dependency '{}@{}' - validation not implemented"#,
620                    dep_name, dep_version
621                ),
622                suggestion: r#"Ensure dependent extensions are installed"#.to_string(),
623            });
624        }
625
626        // 5. Check for script files if they exist
627        if let Some(script_path) = &manifest.extension.main {
628            let script_full_path = agents_dir.join(script_path);
629            if script_full_path.exists() {
630                match std::fs::metadata(&script_full_path) {
631                    Ok(metadata) => {
632                        let permissions = metadata.permissions();
633                        let mode = permissions.mode();
634
635                        // Check if executable by owner
636                        if mode & 0o100 == 0 {
637                            issues.push(DiagnosticIssue {
638                                severity: Severity::Warning,
639                                epic_tag: "extensions".to_string(),
640                                task_id: Some(filename.to_string()),
641                                message: format!(
642                                    "Script file not executable: {}",
643                                    script_full_path.display()
644                                ),
645                                suggestion: r#"Run: chmod +x <script_file>.py"#.to_string(),
646                            });
647                        }
648                    }
649                    Err(e) => {
650                        issues.push(DiagnosticIssue {
651                            severity: Severity::Error,
652                            epic_tag: "extensions".to_string(),
653                            task_id: Some(filename.to_string()),
654                            message: format!("Cannot access script file: {}", e),
655                            suggestion: r#"Check script file permissions"#.to_string(),
656                        });
657                    }
658                }
659            } else {
660                issues.push(DiagnosticIssue {
661                    severity: Severity::Warning,
662                    epic_tag: "extensions".to_string(),
663                    task_id: Some(filename.to_string()),
664                    message: format!(
665                        "Referenced script file does not exist: {}",
666                        script_full_path.display()
667                    ),
668                    suggestion: r#"Create the script file or update manifest"#.to_string(),
669                });
670            }
671        }
672
673        // Check tool scripts
674        for tool in &manifest.tools {
675            if let Some(script_path) = &tool.script {
676                let script_full_path = agents_dir.join(script_path);
677                if script_full_path.exists() {
678                    match std::fs::metadata(&script_full_path) {
679                        Ok(metadata) => {
680                            let permissions = metadata.permissions();
681                            let mode = permissions.mode();
682
683                            if mode & 0o100 == 0 {
684                                issues.push(DiagnosticIssue {
685                                    severity: Severity::Warning,
686                                    epic_tag: "extensions".to_string(),
687                                    task_id: Some(format!("{}/{}", filename, tool.name)),
688                                    message: format!(
689                                        "Tool script not executable: {}",
690                                        script_full_path.display()
691                                    ),
692                                    suggestion: r#"Run: chmod +x <script_file>.py"#.to_string(),
693                                });
694                            }
695                        }
696                        Err(e) => {
697                            issues.push(DiagnosticIssue {
698                                severity: Severity::Error,
699                                epic_tag: "extensions".to_string(),
700                                task_id: Some(format!("{}/{}", filename, tool.name)),
701                                message: format!("Cannot access tool script: {}", e),
702                                suggestion: r#"Check script file permissions"#.to_string(),
703                            });
704                        }
705                    }
706                } else {
707                    issues.push(DiagnosticIssue {
708                        severity: Severity::Error,
709                        epic_tag: "extensions".to_string(),
710                        task_id: Some(format!("{}/{}", filename, tool.name)),
711                        message: format!(
712                            "Tool script does not exist: {}",
713                            script_full_path.display()
714                        ),
715                        suggestion: r#"Create the script file or update manifest"#.to_string(),
716                    });
717                }
718            }
719        }
720
721        // Check event handler scripts
722        for event in &manifest.events {
723            if let Some(script_path) = &event.script {
724                let script_full_path = agents_dir.join(script_path);
725                if script_full_path.exists() {
726                    match std::fs::metadata(&script_full_path) {
727                        Ok(metadata) => {
728                            let permissions = metadata.permissions();
729                            let mode = permissions.mode();
730
731                            if mode & 0o100 == 0 {
732                                issues.push(DiagnosticIssue {
733                                    severity: Severity::Warning,
734                                    epic_tag: "extensions".to_string(),
735                                    task_id: Some(format!("{}/{}", filename, event.event)),
736                                    message: format!(
737                                        "Event handler script not executable: {}",
738                                        script_full_path.display()
739                                    ),
740                                    suggestion: r#"Run: chmod +x <script_file>.py"#.to_string(),
741                                });
742                            }
743                        }
744                        Err(e) => {
745                            issues.push(DiagnosticIssue {
746                                severity: Severity::Error,
747                                epic_tag: "extensions".to_string(),
748                                task_id: Some(format!("{}/{}", filename, event.event)),
749                                message: format!("Cannot access event handler script: {}", e),
750                                suggestion: r#"Check script file permissions"#.to_string(),
751                            });
752                        }
753                    }
754                } else {
755                    issues.push(DiagnosticIssue {
756                        severity: Severity::Error,
757                        epic_tag: "extensions".to_string(),
758                        task_id: Some(format!("{}/{}", filename, event.event)),
759                        message: format!(
760                            "Event handler script does not exist: {}",
761                            script_full_path.display()
762                        ),
763                        suggestion: r#"Create the script file or update manifest"#.to_string(),
764                    });
765                }
766            }
767        }
768    }
769
770    print_scan_results(&issues, scanned_count)
771}
772
773fn print_scan_results(issues: &[DiagnosticIssue], scanned_count: usize) -> Result<()> {
774    println!("Scanned {} extension(s)", scanned_count);
775    println!();
776
777    if issues.is_empty() {
778        println!(
779            "{}",
780            "\u{2713} All extensions are valid and properly configured!"
781                .green()
782                .bold()
783        );
784        return Ok(());
785    }
786
787    let critical_count = issues
788        .iter()
789        .filter(|i| i.severity == Severity::Critical)
790        .count();
791    let error_count = issues
792        .iter()
793        .filter(|i| i.severity == Severity::Error)
794        .count();
795    let warning_count = issues
796        .iter()
797        .filter(|i| i.severity == Severity::Warning)
798        .count();
799
800    // Print issues by severity
801    for severity in &[Severity::Critical, Severity::Error, Severity::Warning] {
802        let severity_issues: Vec<_> = issues.iter().filter(|i| i.severity == *severity).collect();
803
804        if severity_issues.is_empty() {
805            continue;
806        }
807
808        let title = match severity {
809            Severity::Critical => "CRITICAL ISSUES".red().bold(),
810            Severity::Error => "ERRORS".red().bold(),
811            Severity::Warning => "WARNINGS".yellow().bold(),
812        };
813
814        println!("{}", title);
815        println!("{}", "-".repeat(40));
816
817        for issue in severity_issues {
818            let icon = match severity {
819                Severity::Critical => "\u{2717}".red(),
820                Severity::Error => "\u{2717}".red(),
821                Severity::Warning => "\u{26A0}".yellow(),
822            };
823
824            println!("  {} {}", icon, issue.message);
825
826            if let Some(ref task_id) = issue.task_id {
827                println!("    Extension: {}", task_id.cyan());
828            }
829
830            println!("    {}", format!("\u{2192} {}", issue.suggestion).dimmed());
831            println!();
832        }
833    }
834
835    // Summary
836    println!("{}", "Summary".blue().bold());
837    println!("{}", "-".repeat(40).blue());
838    println!(
839        "  Critical: {}  Errors: {}  Warnings: {}",
840        critical_count.to_string().red(),
841        error_count.to_string().red(),
842        warning_count.to_string().yellow()
843    );
844
845    if critical_count > 0 {
846        println!();
847        println!(
848            "{}",
849            "Critical issues prevent extensions from functioning. Fix them first.".red()
850        );
851    }
852
853    Ok(())
854}
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859    use crate::models::phase::Phase;
860    use crate::models::task::Task;
861
862    #[test]
863    fn test_diagnostic_results_has_issues() {
864        let empty = DiagnosticResults::default();
865        assert!(!empty.has_issues());
866
867        let mut with_orphan = DiagnosticResults::default();
868        with_orphan
869            .orphan_in_progress
870            .push(("epic".to_string(), "task".to_string()));
871        assert!(with_orphan.has_issues());
872    }
873
874    #[test]
875    fn test_diagnostic_results_counts() {
876        let mut results = DiagnosticResults::default();
877
878        // Add orphan in-progress (warnings)
879        results
880            .orphan_in_progress
881            .push(("epic".to_string(), "task1".to_string()));
882        results
883            .orphan_in_progress
884            .push(("epic".to_string(), "task2".to_string()));
885
886        // Add blocked by cancelled (errors)
887        results.blocked_by_cancelled.push((
888            "epic".to_string(),
889            "task3".to_string(),
890            "dep1".to_string(),
891        ));
892
893        // Add corrupt files (critical)
894        results
895            .corrupt_files
896            .push("tasks.json: parse error".to_string());
897
898        assert_eq!(results.warning_count(), 2);
899        assert_eq!(results.error_count(), 1);
900        assert_eq!(results.critical_count(), 1);
901    }
902
903    #[test]
904    fn test_severity_as_str() {
905        assert_eq!(Severity::Warning.as_str(), "WARNING");
906        assert_eq!(Severity::Error.as_str(), "ERROR");
907        assert_eq!(Severity::Critical.as_str(), "CRITICAL");
908    }
909
910    fn create_test_phase_with_issues() -> Phase {
911        let mut phase = Phase::new("test-phase".to_string());
912
913        // Task 1: Done
914        let mut task1 = Task::new("1".to_string(), "Task 1".to_string(), "Desc".to_string());
915        task1.set_status(TaskStatus::Done);
916        phase.add_task(task1);
917
918        // Task 2: Cancelled (will block task 3)
919        let mut task2 = Task::new("2".to_string(), "Task 2".to_string(), "Desc".to_string());
920        task2.set_status(TaskStatus::Cancelled);
921        phase.add_task(task2);
922
923        // Task 3: Pending, depends on cancelled task 2
924        let mut task3 = Task::new("3".to_string(), "Task 3".to_string(), "Desc".to_string());
925        task3.dependencies = vec!["2".to_string()];
926        phase.add_task(task3);
927
928        // Task 4: Pending, depends on non-existent task
929        let mut task4 = Task::new("4".to_string(), "Task 4".to_string(), "Desc".to_string());
930        task4.dependencies = vec!["nonexistent".to_string()];
931        phase.add_task(task4);
932
933        phase
934    }
935
936    #[test]
937    fn test_detect_cancelled_dependency() {
938        let phase = create_test_phase_with_issues();
939
940        let task3 = phase.get_task("3").unwrap();
941        let mut found_cancelled_dep = false;
942
943        for dep_id in &task3.dependencies {
944            if let Some(dep_task) = phase.get_task(dep_id) {
945                if dep_task.status == TaskStatus::Cancelled {
946                    found_cancelled_dep = true;
947                }
948            }
949        }
950
951        assert!(found_cancelled_dep);
952    }
953
954    #[test]
955    fn test_detect_missing_dependency() {
956        let phase = create_test_phase_with_issues();
957        let all_task_ids: std::collections::HashSet<_> =
958            phase.tasks.iter().map(|t| t.id.clone()).collect();
959        // Use all_task_ids to check for missing dependencies
960        let _task_count = all_task_ids.len();
961
962        let task4 = phase.get_task("4").unwrap();
963        let mut found_missing_dep = false;
964
965        for dep_id in &task4.dependencies {
966            if !all_task_ids.contains(dep_id) {
967                found_missing_dep = true;
968            }
969        }
970
971        assert!(found_missing_dep);
972    }
973}