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#[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#[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#[derive(Debug, Default)]
39pub struct DiagnosticResults {
40 pub issues: Vec<DiagnosticIssue>,
41 pub blocked_by_cancelled: Vec<(String, String, String)>, pub blocked_by_missing: Vec<(String, String, String)>, pub orphan_in_progress: Vec<(String, String)>, 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 let tasks_result = storage.load_tasks();
113
114 let mut results = DiagnosticResults::default();
115
116 if let Err(ref e) = tasks_result {
118 results.corrupt_files.push(format!("tasks file: {}", e));
119 }
120
121 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 !results.corrupt_files.is_empty() {
134 print_results(&results, fix);
135 return Ok(());
136 }
137
138 let mut all_tasks = tasks_result?;
139
140 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 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 let all_task_ids: HashSet<_> = epic.tasks.iter().map(|t| t.id.clone()).collect();
160
161 for task in &epic.tasks {
162 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 if task.status == TaskStatus::Pending {
179 for dep_id in &task.dependencies {
180 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 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 if fix && results.has_issues() {
237 println!("{}", "Attempting auto-fixes...".yellow());
238 println!();
239
240 let mut fixed_count = 0;
241
242 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 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 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 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 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 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 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 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 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 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 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 match std::fs::metadata(&path) {
547 Ok(metadata) => {
548 let permissions = metadata.permissions();
549 let mode = permissions.mode();
550
551 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 if let Some(config) = manifest.config.get("harness") {
575 if let Some(harness_str) = config.as_str() {
576 match Harness::parse(harness_str) {
578 Ok(harness) => {
579 match find_harness_binary(harness) {
581 Ok(_) => {
582 }
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 for (dep_name, dep_version) in &manifest.dependencies {
613 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 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 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 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 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 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 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 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 results.blocked_by_cancelled.push((
888 "epic".to_string(),
889 "task3".to_string(),
890 "dep1".to_string(),
891 ));
892
893 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 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 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 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 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 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}