Skip to main content

chant/
diagnose.rs

1//! Diagnostic utilities for checking spec execution status.
2//!
3//! # Doc Audit
4//! - audited: 2026-01-25
5//! - docs: guides/recovery.md, reference/cli.md
6//! - ignore: false
7
8use anyhow::{Context, Result};
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13use crate::paths::SPECS_DIR;
14use crate::spec::{Spec, SpecStatus};
15use crate::worktree::get_active_worktree;
16
17/// A single diagnostic check result.
18#[derive(Debug, Clone)]
19pub struct CheckResult {
20    pub name: String,
21    pub passed: bool,
22    pub details: Option<String>,
23}
24
25impl CheckResult {
26    pub fn pass(name: impl Into<String>) -> Self {
27        Self {
28            name: name.into(),
29            passed: true,
30            details: None,
31        }
32    }
33
34    pub fn pass_with_details(name: impl Into<String>, details: impl Into<String>) -> Self {
35        Self {
36            name: name.into(),
37            passed: true,
38            details: Some(details.into()),
39        }
40    }
41
42    pub fn fail(name: impl Into<String>, details: impl Into<String>) -> Self {
43        Self {
44            name: name.into(),
45            passed: false,
46            details: Some(details.into()),
47        }
48    }
49}
50
51/// Full diagnostic report for a spec.
52#[derive(Debug, Clone)]
53pub struct DiagnosticReport {
54    pub spec_id: String,
55    pub status: SpecStatus,
56    pub checks: Vec<CheckResult>,
57    pub diagnosis: String,
58    pub suggestion: Option<String>,
59    pub location: String,
60}
61
62impl DiagnosticReport {
63    /// Returns true if all diagnostic checks passed. Used in tests.
64    pub fn all_passed(&self) -> bool {
65        self.checks.iter().all(|c| c.passed)
66    }
67
68    /// Returns the list of failed checks. Used in tests.
69    pub fn failed_checks(&self) -> Vec<&CheckResult> {
70        self.checks.iter().filter(|c| !c.passed).collect()
71    }
72}
73
74/// Check if a spec file exists and is valid.
75fn check_spec_file(spec_file: &Path) -> CheckResult {
76    if !spec_file.exists() {
77        return CheckResult::fail("Spec file", "Does not exist");
78    }
79
80    match fs::read_to_string(spec_file) {
81        Ok(content) => {
82            // Try to parse as a spec to validate YAML
83            match Spec::parse(
84                spec_file
85                    .file_stem()
86                    .and_then(|s| s.to_str())
87                    .unwrap_or("unknown"),
88                &content,
89            ) {
90                Ok(_) => CheckResult::pass_with_details("Spec file", "Valid YAML"),
91                Err(e) => CheckResult::fail("Spec file", format!("Invalid YAML: {}", e)),
92            }
93        }
94        Err(e) => CheckResult::fail("Spec file", format!("Cannot read: {}", e)),
95    }
96}
97
98/// Check if a log file exists and get its age.
99fn check_log_file(spec_id: &str, base_path: &Path) -> CheckResult {
100    let log_file = base_path
101        .join(".chant/logs")
102        .join(format!("{}.log", spec_id));
103
104    let location_hint = if base_path.to_string_lossy().contains("/tmp/chant-") {
105        "worktree"
106    } else {
107        "main"
108    };
109
110    if !log_file.exists() {
111        return CheckResult::fail(
112            "Log file",
113            format!("Does not exist (checked in {})", location_hint),
114        );
115    }
116
117    match fs::metadata(&log_file) {
118        Ok(metadata) => {
119            let size = metadata.len();
120
121            let age_str = match metadata.modified() {
122                Ok(modified_time) => match modified_time.elapsed() {
123                    Ok(elapsed) => {
124                        let secs: u64 = elapsed.as_secs();
125                        if secs < 60 {
126                            "just now".to_string()
127                        } else if secs < 3600 {
128                            format!("{} minutes ago", secs / 60)
129                        } else if secs < 86400 {
130                            format!("{} hours ago", secs / 3600)
131                        } else {
132                            format!("{} days ago", secs / 86400)
133                        }
134                    }
135                    Err(_) => "unknown age".to_string(),
136                },
137                Err(_) => "unknown age".to_string(),
138            };
139
140            CheckResult::pass_with_details(
141                "Log file",
142                format!(
143                    "Exists ({} bytes), last modified: {} (checked in {})",
144                    size, age_str, location_hint
145                ),
146            )
147        }
148        Err(e) => CheckResult::fail("Log file", format!("Cannot read metadata: {}", e)),
149    }
150}
151
152/// Check if there's a lock file.
153fn check_lock_file(spec_id: &str, base_path: &Path) -> CheckResult {
154    let lock_file = base_path
155        .join(".chant/.locks")
156        .join(format!("{}.lock", spec_id));
157
158    let location_hint = if base_path.to_string_lossy().contains("/tmp/chant-") {
159        "worktree"
160    } else {
161        "main"
162    };
163
164    if lock_file.exists() {
165        CheckResult::pass_with_details(
166            "Lock file",
167            format!(
168                "Present (spec may be running) (checked in {})",
169                location_hint
170            ),
171        )
172    } else {
173        CheckResult::pass_with_details(
174            "Lock file",
175            format!("Not present (checked in {})", location_hint),
176        )
177    }
178}
179
180/// Check if a git commit exists for this spec.
181fn check_git_commit(spec_id: &str) -> CheckResult {
182    let output = Command::new("git")
183        .args(["log", "--grep", &format!("chant({})", spec_id), "--oneline"])
184        .output();
185
186    match output {
187        Ok(output) => {
188            let stdout = String::from_utf8_lossy(&output.stdout);
189            let commit_line = stdout.lines().next();
190
191            if let Some(line) = commit_line {
192                // Extract commit hash (first 7 chars)
193                let commit_hash = line.split_whitespace().next().unwrap_or("unknown");
194                CheckResult::pass_with_details("Git commit", format!("Found: {}", commit_hash))
195            } else {
196                CheckResult::fail("Git commit", "No matching commit found")
197            }
198        }
199        Err(_) => CheckResult::fail("Git commit", "Cannot run git log"),
200    }
201}
202
203/// Check if acceptance criteria are all satisfied.
204fn check_acceptance_criteria(spec: &Spec) -> CheckResult {
205    let unchecked = spec.count_unchecked_checkboxes();
206
207    if unchecked == 0 {
208        CheckResult::pass_with_details("Acceptance criteria", "All satisfied")
209    } else {
210        CheckResult::fail(
211            "Acceptance criteria",
212            format!("{} unchecked items remaining", unchecked),
213        )
214    }
215}
216
217/// Check for common status mismatches.
218fn check_status_consistency(spec: &Spec, commit_exists: bool, unchecked: usize) -> CheckResult {
219    match spec.frontmatter.status {
220        SpecStatus::InProgress => {
221            if commit_exists {
222                CheckResult::fail(
223                    "Status consistency",
224                    "Status is in_progress but commit exists (should be completed?)",
225                )
226            } else {
227                CheckResult::pass("Status consistency")
228            }
229        }
230        SpecStatus::Paused => CheckResult::pass_with_details(
231            "Status consistency",
232            "Paused (work stopped mid-execution)",
233        ),
234        SpecStatus::Completed => {
235            if !commit_exists {
236                CheckResult::fail(
237                    "Status consistency",
238                    "Status is completed but no commit found",
239                )
240            } else if unchecked > 0 {
241                CheckResult::fail(
242                    "Status consistency",
243                    "Status is completed but acceptance criteria unchecked",
244                )
245            } else {
246                CheckResult::pass("Status consistency")
247            }
248        }
249        SpecStatus::Pending => {
250            if commit_exists {
251                CheckResult::fail("Status consistency", "Status is pending but commit exists")
252            } else {
253                CheckResult::pass("Status consistency")
254            }
255        }
256        SpecStatus::Failed => {
257            CheckResult::pass_with_details("Status consistency", "Marked as failed")
258        }
259        SpecStatus::NeedsAttention => {
260            CheckResult::pass_with_details("Status consistency", "Marked as needs attention")
261        }
262        SpecStatus::Ready => {
263            if commit_exists {
264                CheckResult::fail("Status consistency", "Status is ready but commit exists")
265            } else {
266                CheckResult::pass("Status consistency")
267            }
268        }
269        SpecStatus::Blocked => {
270            CheckResult::pass_with_details("Status consistency", "Blocked by unmet dependencies")
271        }
272        SpecStatus::Cancelled => CheckResult::pass_with_details(
273            "Status consistency",
274            "Marked as cancelled (preserved but excluded from list and work)",
275        ),
276    }
277}
278
279/// Run all diagnostic checks on a spec.
280pub fn diagnose_spec(spec_id: &str) -> Result<DiagnosticReport> {
281    // Check if spec has an active worktree
282    let (base_path, location) = if let Some(worktree) = get_active_worktree(spec_id, None) {
283        (
284            worktree.clone(),
285            format!("worktree: {}", worktree.display()),
286        )
287    } else {
288        (PathBuf::from("."), "main repository".to_string())
289    };
290
291    let specs_dir = base_path.join(SPECS_DIR);
292    let spec_file = specs_dir.join(format!("{}.md", spec_id));
293
294    // Load the spec
295    let spec = Spec::load(&spec_file).context("Failed to load spec")?;
296
297    // Run checks
298    let mut checks = Vec::new();
299
300    // 1. Spec file check
301    checks.push(check_spec_file(&spec_file));
302
303    // 2. Log file check
304    checks.push(check_log_file(spec_id, &base_path));
305
306    // 3. Lock file check
307    checks.push(check_lock_file(spec_id, &base_path));
308
309    // 4. Git commit check
310    let commit_result = check_git_commit(spec_id);
311    let commit_exists = commit_result.passed;
312    checks.push(commit_result);
313
314    // 5. Acceptance criteria check
315    let criteria_result = check_acceptance_criteria(&spec);
316    let unchecked = spec.count_unchecked_checkboxes();
317    checks.push(criteria_result);
318
319    // 6. Status consistency check
320    checks.push(check_status_consistency(&spec, commit_exists, unchecked));
321
322    // Determine diagnosis and suggestion
323    let (diagnosis, suggestion) = diagnose_issues(&spec, &checks);
324
325    Ok(DiagnosticReport {
326        spec_id: spec_id.to_string(),
327        status: spec.frontmatter.status.clone(),
328        checks,
329        diagnosis,
330        suggestion,
331        location,
332    })
333}
334
335/// Generate a diagnosis message and suggestion based on check results.
336fn diagnose_issues(spec: &Spec, checks: &[CheckResult]) -> (String, Option<String>) {
337    let failed = checks.iter().filter(|c| !c.passed).collect::<Vec<_>>();
338
339    if failed.is_empty() {
340        return ("All checks passed. Spec appears healthy.".to_string(), None);
341    }
342
343    // Look for specific patterns
344    if spec.frontmatter.status == SpecStatus::InProgress {
345        // Check for the common "stuck in progress" pattern
346        let has_commit = checks.iter().any(|c| c.name == "Git commit" && c.passed);
347        let all_criteria_met = checks
348            .iter()
349            .find(|c| c.name == "Acceptance criteria")
350            .map(|c| c.passed)
351            .unwrap_or(false);
352
353        if has_commit && all_criteria_met {
354            return (
355                "Spec appears complete but wasn't finalized.".to_string(),
356                Some(format!(
357                    "Run `just chant work {} --finalize` to fix.",
358                    &spec.id
359                )),
360            );
361        }
362
363        if has_commit && !all_criteria_met {
364            return (
365                "Spec has a commit but acceptance criteria not all satisfied.".to_string(),
366                Some("Complete the unchecked acceptance criteria and re-run the spec.".to_string()),
367            );
368        }
369
370        return (
371            "Spec is in progress but has issues.".to_string(),
372            Some(format!("Check the log: `just chant log {}`", &spec.id)),
373        );
374    }
375
376    // Generic diagnosis for failed checks
377    let check_names = failed
378        .iter()
379        .map(|c| c.name.as_str())
380        .collect::<Vec<_>>()
381        .join(", ");
382
383    (
384        format!("Spec has issues: {}", check_names),
385        Some(format!("Run `just chant log {}` for details", &spec.id)),
386    )
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn test_check_result_creation() {
395        let pass = CheckResult::pass("Test");
396        assert!(pass.passed);
397        assert_eq!(pass.name, "Test");
398        assert_eq!(pass.details, None);
399
400        let fail = CheckResult::fail("Test", "Failed");
401        assert!(!fail.passed);
402        assert_eq!(fail.details, Some("Failed".to_string()));
403    }
404
405    #[test]
406    fn test_spec_file_check_missing() {
407        let result = check_spec_file(Path::new("nonexistent.md"));
408        assert!(!result.passed);
409        assert!(result.details.is_some());
410    }
411
412    #[test]
413    fn test_diagnostic_report_all_passed() {
414        let report = DiagnosticReport {
415            spec_id: "test".to_string(),
416            status: SpecStatus::Completed,
417            checks: vec![CheckResult::pass("Check 1"), CheckResult::pass("Check 2")],
418            diagnosis: "All good".to_string(),
419            suggestion: None,
420            location: "main repository".to_string(),
421        };
422
423        assert!(report.all_passed());
424        assert_eq!(report.failed_checks().len(), 0);
425    }
426
427    #[test]
428    fn test_diagnostic_report_some_failed() {
429        let report = DiagnosticReport {
430            spec_id: "test".to_string(),
431            status: SpecStatus::InProgress,
432            checks: vec![
433                CheckResult::pass("Check 1"),
434                CheckResult::fail("Check 2", "Bad"),
435            ],
436            diagnosis: "Some issues".to_string(),
437            suggestion: None,
438            location: "main repository".to_string(),
439        };
440
441        assert!(!report.all_passed());
442        assert_eq!(report.failed_checks().len(), 1);
443    }
444
445    #[test]
446    fn test_status_consistency_in_progress_with_commit() {
447        use crate::spec::SpecFrontmatter;
448
449        let spec = Spec {
450            id: "test".to_string(),
451            frontmatter: SpecFrontmatter {
452                status: SpecStatus::InProgress,
453                ..Default::default()
454            },
455            title: None,
456            body: String::new(),
457        };
458
459        let result = check_status_consistency(&spec, true, 0);
460        assert!(!result.passed);
461        assert!(result
462            .details
463            .unwrap()
464            .contains("in_progress but commit exists"));
465    }
466
467    #[test]
468    fn test_status_consistency_completed_no_commit() {
469        use crate::spec::SpecFrontmatter;
470
471        let spec = Spec {
472            id: "test".to_string(),
473            frontmatter: SpecFrontmatter {
474                status: SpecStatus::Completed,
475                ..Default::default()
476            },
477            title: None,
478            body: String::new(),
479        };
480
481        let result = check_status_consistency(&spec, false, 0);
482        assert!(!result.passed);
483        assert!(result
484            .details
485            .unwrap()
486            .contains("completed but no commit found"));
487    }
488
489    #[test]
490    fn test_acceptance_criteria_all_satisfied() {
491        let spec = Spec {
492            id: "test".to_string(),
493            frontmatter: Default::default(),
494            title: None,
495            body: "## Acceptance Criteria\n\n- [x] Item 1\n- [x] Item 2".to_string(),
496        };
497
498        let result = check_acceptance_criteria(&spec);
499        assert!(result.passed);
500    }
501
502    #[test]
503    fn test_acceptance_criteria_some_unchecked() {
504        let spec = Spec {
505            id: "test".to_string(),
506            frontmatter: Default::default(),
507            title: None,
508            body: "## Acceptance Criteria\n\n- [ ] Item 1\n- [x] Item 2".to_string(),
509        };
510
511        let result = check_acceptance_criteria(&spec);
512        assert!(!result.passed);
513        assert!(result
514            .details
515            .unwrap()
516            .contains("1 unchecked items remaining"));
517    }
518}