1use 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#[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#[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 pub fn all_passed(&self) -> bool {
65 self.checks.iter().all(|c| c.passed)
66 }
67
68 pub fn failed_checks(&self) -> Vec<&CheckResult> {
70 self.checks.iter().filter(|c| !c.passed).collect()
71 }
72}
73
74fn 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 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
98fn 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
152fn 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
180fn 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 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
203fn 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
217fn 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
279pub fn diagnose_spec(spec_id: &str) -> Result<DiagnosticReport> {
281 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 let spec = Spec::load(&spec_file).context("Failed to load spec")?;
296
297 let mut checks = Vec::new();
299
300 checks.push(check_spec_file(&spec_file));
302
303 checks.push(check_log_file(spec_id, &base_path));
305
306 checks.push(check_lock_file(spec_id, &base_path));
308
309 let commit_result = check_git_commit(spec_id);
311 let commit_exists = commit_result.passed;
312 checks.push(commit_result);
313
314 let criteria_result = check_acceptance_criteria(&spec);
316 let unchecked = spec.count_unchecked_checkboxes();
317 checks.push(criteria_result);
318
319 checks.push(check_status_consistency(&spec, commit_exists, unchecked));
321
322 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
335fn 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 if spec.frontmatter.status == SpecStatus::InProgress {
345 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 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}