1use anyhow::{Context, Result};
7use colored::Colorize;
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11use std::str::FromStr;
12use tokio::task::JoinHandle;
13
14use crate::version::VersionManager;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum HealthCheckType {
20 Git,
21 Version,
22 Tests,
23 Docs,
24 Specs,
25}
26
27impl FromStr for HealthCheckType {
28 type Err = String;
29
30 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
31 match s.to_lowercase().as_str() {
32 "git" => Ok(Self::Git),
33 "version" => Ok(Self::Version),
34 "tests" => Ok(Self::Tests),
35 "docs" => Ok(Self::Docs),
36 "specs" => Ok(Self::Specs),
37 _ => Err(format!("Unknown health check type: {}", s)),
38 }
39 }
40}
41
42impl HealthCheckType {
43 pub fn as_str(&self) -> &'static str {
44 match self {
45 Self::Git => "git",
46 Self::Version => "version",
47 Self::Tests => "tests",
48 Self::Docs => "docs",
49 Self::Specs => "specs",
50 }
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum HealthStatus {
58 Pass,
59 Warn,
60 Fail,
61}
62
63impl HealthStatus {
64 pub fn is_critical(&self) -> bool {
65 matches!(self, Self::Fail)
66 }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct HealthCheckResult {
72 pub check_type: HealthCheckType,
73 pub status: HealthStatus,
74 pub message: String,
75 pub details: Vec<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct GitStatus {
81 pub repo_path: PathBuf,
82 pub branch: String,
83 pub is_dirty: bool,
84 pub ahead: usize,
85 pub behind: usize,
86 pub has_upstream: bool,
87 pub dirty_files: Vec<String>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct HealthReport {
93 pub timestamp: String,
94 pub workspace_root: PathBuf,
95 pub checks: Vec<HealthCheckResult>,
96 pub overall_status: HealthStatus,
97}
98
99impl HealthReport {
100 pub fn has_failures(&self) -> bool {
102 self.checks.iter().any(|c| c.status.is_critical())
103 }
104
105 pub fn to_markdown(&self) -> String {
107 let mut output = String::new();
108
109 output.push_str("# Workspace Health Report\n\n");
110 output.push_str(&format!("**Generated:** {}\n", self.timestamp));
111 output.push_str(&format!(
112 "**Workspace:** `{}`\n\n",
113 self.workspace_root.display()
114 ));
115
116 let status_emoji = match self.overall_status {
117 HealthStatus::Pass => "✅",
118 HealthStatus::Warn => "⚠️",
119 HealthStatus::Fail => "❌",
120 };
121 output.push_str(&format!(
122 "**Overall Status:** {} {:?}\n\n",
123 status_emoji, self.overall_status
124 ));
125
126 output.push_str("## Check Results\n\n");
127
128 for check in &self.checks {
129 let icon = match check.status {
130 HealthStatus::Pass => "✅",
131 HealthStatus::Warn => "⚠️",
132 HealthStatus::Fail => "❌",
133 };
134
135 output.push_str(&format!(
136 "### {} {} Check\n\n",
137 icon,
138 check.check_type.as_str()
139 ));
140 output.push_str(&format!("**Status:** {:?}\n\n", check.status));
141 output.push_str(&format!("{}\n\n", check.message));
142
143 if !check.details.is_empty() {
144 output.push_str("**Details:**\n\n");
145 for detail in &check.details {
146 output.push_str(&format!("- {}\n", detail));
147 }
148 output.push('\n');
149 }
150 }
151
152 output
153 }
154
155 pub fn print_terminal(&self, verbose: bool) {
157 println!("\n{}", "═".repeat(80).bright_black());
158 println!("{}", "Workspace Health Report".bright_white().bold());
159 println!("{}", "═".repeat(80).bright_black());
160
161 println!("{} {}", "Generated:".cyan(), self.timestamp);
162 println!("{} {}", "Workspace:".cyan(), self.workspace_root.display());
163
164 let status_text = match self.overall_status {
165 HealthStatus::Pass => "PASS".green().bold(),
166 HealthStatus::Warn => "WARN".yellow().bold(),
167 HealthStatus::Fail => "FAIL".red().bold(),
168 };
169 println!("{} {}\n", "Overall Status:".cyan(), status_text);
170
171 for check in &self.checks {
172 let icon = match check.status {
173 HealthStatus::Pass => "✓".green(),
174 HealthStatus::Warn => "⚠".yellow(),
175 HealthStatus::Fail => "✗".red(),
176 };
177
178 println!(
179 "{} {} {}",
180 icon,
181 check.check_type.as_str().bright_white().bold(),
182 format!("[{:?}]", check.status).dimmed()
183 );
184 println!(" {}", check.message);
185
186 if verbose && !check.details.is_empty() {
187 for detail in &check.details {
188 println!(" • {}", detail.dimmed());
189 }
190 } else if !verbose && check.details.len() > 3 {
191 for detail in check.details.iter().take(3) {
192 println!(" • {}", detail.dimmed());
193 }
194 println!(
195 " {} {} more details (use --verbose)",
196 "...".dimmed(),
197 (check.details.len() - 3).to_string().dimmed()
198 );
199 } else if !check.details.is_empty() {
200 for detail in &check.details {
201 println!(" • {}", detail.dimmed());
202 }
203 }
204 println!();
205 }
206
207 println!("{}", "═".repeat(80).bright_black());
208 }
209}
210
211pub struct HealthChecker {
213 workspace_root: PathBuf,
214}
215
216impl HealthChecker {
217 pub fn new(workspace_root: impl AsRef<Path>) -> Self {
219 let workspace_root = workspace_root.as_ref().to_path_buf();
220 Self { workspace_root }
221 }
222
223 pub async fn check_all(&self, verbose: bool) -> Result<HealthReport> {
225 let checks = vec![
226 HealthCheckType::Git,
227 HealthCheckType::Version,
228 HealthCheckType::Tests,
229 HealthCheckType::Docs,
230 HealthCheckType::Specs,
231 ];
232
233 self.check_selected(&checks, verbose).await
234 }
235
236 pub async fn check_selected(
238 &self,
239 check_types: &[HealthCheckType],
240 verbose: bool,
241 ) -> Result<HealthReport> {
242 let mut handles: Vec<JoinHandle<Result<HealthCheckResult>>> = Vec::new();
243
244 for &check_type in check_types {
245 let workspace_root = self.workspace_root.clone();
246
247 let handle = tokio::spawn(async move {
248 match check_type {
249 HealthCheckType::Git => {
250 Self::check_git_status_static(&workspace_root, verbose).await
251 }
252 HealthCheckType::Version => {
253 Self::check_version_alignment_static(&workspace_root, verbose).await
254 }
255 HealthCheckType::Tests => {
256 Self::check_tests_static(&workspace_root, verbose).await
257 }
258 HealthCheckType::Docs => {
259 Self::check_docs_static(&workspace_root, verbose).await
260 }
261 HealthCheckType::Specs => {
262 Self::check_spec_coverage_static(&workspace_root, verbose).await
263 }
264 }
265 });
266
267 handles.push(handle);
268 }
269
270 let mut results = Vec::new();
271 for handle in handles {
272 match handle.await {
273 Ok(Ok(result)) => results.push(result),
274 Ok(Err(e)) => {
275 eprintln!("Health check failed: {}", e);
276 }
278 Err(e) => {
279 eprintln!("Task panicked: {}", e);
280 }
281 }
282 }
283
284 let overall_status = if results.iter().any(|r| r.status == HealthStatus::Fail) {
286 HealthStatus::Fail
287 } else if results.iter().any(|r| r.status == HealthStatus::Warn) {
288 HealthStatus::Warn
289 } else {
290 HealthStatus::Pass
291 };
292
293 Ok(HealthReport {
294 timestamp: chrono::Local::now().to_rfc3339(),
295 workspace_root: self.workspace_root.clone(),
296 checks: results,
297 overall_status,
298 })
299 }
300
301 async fn check_git_status_static(
303 workspace_root: &Path,
304 verbose: bool,
305 ) -> Result<HealthCheckResult> {
306 let repos = Self::find_git_repos_static(workspace_root)?;
307 let mut all_clean = true;
308 let mut details = Vec::new();
309 let mut warnings = Vec::new();
310
311 for repo_path in &repos {
312 match Self::get_git_status_static(repo_path) {
313 Ok(status) => {
314 let repo_name = repo_path
315 .strip_prefix(workspace_root)
316 .unwrap_or(repo_path)
317 .display()
318 .to_string();
319
320 if status.is_dirty {
321 all_clean = false;
322 details.push(format!(
323 "{}: {} dirty file(s) on branch {}",
324 repo_name,
325 status.dirty_files.len(),
326 status.branch
327 ));
328
329 if verbose {
330 for file in &status.dirty_files {
331 details.push(format!(" - {}", file));
332 }
333 }
334 }
335
336 if status.ahead > 0 || status.behind > 0 {
337 warnings.push(format!(
338 "{}: {} ahead, {} behind upstream on {}",
339 repo_name, status.ahead, status.behind, status.branch
340 ));
341 }
342
343 if !status.has_upstream {
344 warnings.push(format!(
345 "{}: no upstream configured for {}",
346 repo_name, status.branch
347 ));
348 }
349 }
350 Err(e) => {
351 warnings.push(format!("Failed to check {}: {}", repo_path.display(), e));
352 }
353 }
354 }
355
356 let status = if !all_clean {
357 HealthStatus::Fail
358 } else if !warnings.is_empty() {
359 HealthStatus::Warn
360 } else {
361 HealthStatus::Pass
362 };
363
364 let message = if all_clean && warnings.is_empty() {
365 format!("All {} repositories are clean and synced", repos.len())
366 } else if !all_clean {
367 format!(
368 "Found {} repositories with uncommitted changes",
369 details.len()
370 )
371 } else {
372 format!("All repositories clean, {} warning(s)", warnings.len())
373 };
374
375 details.extend(warnings);
376
377 Ok(HealthCheckResult {
378 check_type: HealthCheckType::Git,
379 status,
380 message,
381 details,
382 })
383 }
384
385 async fn check_version_alignment_static(
387 workspace_root: &Path,
388 _verbose: bool,
389 ) -> Result<HealthCheckResult> {
390 let version_manager = VersionManager::new(workspace_root);
391
392 match version_manager.check_consistency() {
393 Ok(report) => {
394 let status = if report.has_issues() {
395 HealthStatus::Fail
396 } else {
397 HealthStatus::Pass
398 };
399
400 let message = if report.has_issues() {
401 format!(
402 "Version inconsistencies detected: {} issue(s), {} dependency mismatch(es)",
403 report.issues.len(),
404 report.inconsistencies.len()
405 )
406 } else {
407 format!(
408 "All {} packages have consistent versions",
409 report.total_packages
410 )
411 };
412
413 let mut details = Vec::new();
414
415 for issue in &report.issues {
416 details.push(issue.clone());
417 }
418
419 for inc in &report.inconsistencies {
420 details.push(format!(
421 "{} depends on {} {} (expected: {})",
422 inc.package, inc.dependency, inc.found, inc.expected
423 ));
424 }
425
426 Ok(HealthCheckResult {
427 check_type: HealthCheckType::Version,
428 status,
429 message,
430 details,
431 })
432 }
433 Err(e) => Ok(HealthCheckResult {
434 check_type: HealthCheckType::Version,
435 status: HealthStatus::Fail,
436 message: format!("Failed to check versions: {}", e),
437 details: vec![],
438 }),
439 }
440 }
441
442 async fn check_tests_static(
444 workspace_root: &Path,
445 _verbose: bool,
446 ) -> Result<HealthCheckResult> {
447 let packages = Self::find_packages_static(workspace_root)?;
448 let mut passed = 0;
449 let mut failed = 0;
450 let mut details = Vec::new();
451
452 for pkg_path in &packages {
453 let output = Command::new("cargo")
454 .arg("test")
455 .arg("--manifest-path")
456 .arg(pkg_path.join("Cargo.toml"))
457 .arg("--all-features")
458 .arg("--")
459 .arg("--test-threads=1")
460 .arg("--quiet")
461 .stdout(Stdio::piped())
462 .stderr(Stdio::piped())
463 .output();
464
465 let pkg_name = pkg_path
466 .file_name()
467 .and_then(|n| n.to_str())
468 .unwrap_or("unknown");
469
470 match output {
471 Ok(output) => {
472 if output.status.success() {
473 passed += 1;
474 } else {
475 failed += 1;
476 let stderr = String::from_utf8_lossy(&output.stderr);
477 details.push(format!("{}: tests failed", pkg_name));
478
479 for line in stderr.lines() {
481 if line.contains("test result:") || line.contains("FAILED") {
482 details.push(format!(" {}", line.trim()));
483 }
484 }
485 }
486 }
487 Err(e) => {
488 failed += 1;
489 details.push(format!("{}: failed to run tests: {}", pkg_name, e));
490 }
491 }
492 }
493
494 let status = if failed > 0 {
495 HealthStatus::Fail
496 } else {
497 HealthStatus::Pass
498 };
499
500 let message = format!(
501 "Tests: {} passed, {} failed out of {} packages",
502 passed,
503 failed,
504 packages.len()
505 );
506
507 Ok(HealthCheckResult {
508 check_type: HealthCheckType::Tests,
509 status,
510 message,
511 details,
512 })
513 }
514
515 async fn check_docs_static(workspace_root: &Path, _verbose: bool) -> Result<HealthCheckResult> {
517 let packages = Self::find_packages_static(workspace_root)?;
518 let mut passed = 0;
519 let mut warnings = 0;
520 let mut details = Vec::new();
521
522 for pkg_path in &packages {
523 let output = Command::new("cargo")
524 .arg("rustdoc")
525 .arg("--manifest-path")
526 .arg(pkg_path.join("Cargo.toml"))
527 .arg("--")
528 .arg("-D")
529 .arg("warnings")
530 .arg("--document-private-items")
531 .stdout(Stdio::piped())
532 .stderr(Stdio::piped())
533 .output();
534
535 let pkg_name = pkg_path
536 .file_name()
537 .and_then(|n| n.to_str())
538 .unwrap_or("unknown");
539
540 match output {
541 Ok(output) => {
542 if output.status.success() {
543 passed += 1;
544 } else {
545 warnings += 1;
546 let stderr = String::from_utf8_lossy(&output.stderr);
547 let warning_count = stderr
548 .lines()
549 .filter(|l| {
550 l.contains("warning:") || l.contains("missing documentation")
551 })
552 .count();
553
554 if warning_count > 0 {
555 details.push(format!(
556 "{}: {} documentation warning(s)",
557 pkg_name, warning_count
558 ));
559 }
560 }
561 }
562 Err(e) => {
563 warnings += 1;
564 details.push(format!("{}: failed to check docs: {}", pkg_name, e));
565 }
566 }
567 }
568
569 let status = if warnings > 0 {
570 HealthStatus::Warn
571 } else {
572 HealthStatus::Pass
573 };
574
575 let message = format!(
576 "Documentation: {} clean, {} with warnings out of {} packages",
577 passed,
578 warnings,
579 packages.len()
580 );
581
582 Ok(HealthCheckResult {
583 check_type: HealthCheckType::Docs,
584 status,
585 message,
586 details,
587 })
588 }
589
590 async fn check_spec_coverage_static(
592 workspace_root: &Path,
593 _verbose: bool,
594 ) -> Result<HealthCheckResult> {
595 let packages = Self::find_packages_static(workspace_root)?;
596 let mut with_specs = 0;
597 let mut without_specs = 0;
598 let mut details = Vec::new();
599
600 for pkg_path in &packages {
601 let specs_dir = pkg_path.join("specs");
602 let pkg_name = pkg_path
603 .file_name()
604 .and_then(|n| n.to_str())
605 .unwrap_or("unknown");
606
607 if specs_dir.exists() && specs_dir.is_dir() {
608 let spec_count = walkdir::WalkDir::new(&specs_dir)
610 .into_iter()
611 .filter_map(|e| e.ok())
612 .filter(|e| e.file_type().is_file())
613 .filter(|e| {
614 e.path()
615 .extension()
616 .and_then(|ext| ext.to_str())
617 .map(|ext| ext == "md" || ext == "txt")
618 .unwrap_or(false)
619 })
620 .count();
621
622 with_specs += 1;
623 if spec_count > 0 {
624 details.push(format!("{}: {} spec file(s)", pkg_name, spec_count));
625 }
626 } else {
627 without_specs += 1;
628 details.push(format!("{}: missing specs/ directory", pkg_name));
629 }
630 }
631
632 let total = packages.len();
633 let coverage_pct = if total > 0 {
634 (with_specs as f64 / total as f64) * 100.0
635 } else {
636 0.0
637 };
638
639 let status = if without_specs > 0 {
640 HealthStatus::Warn
641 } else {
642 HealthStatus::Pass
643 };
644
645 let message = format!(
646 "Spec coverage: {:.1}% ({}/{} packages with specs/)",
647 coverage_pct, with_specs, total
648 );
649
650 Ok(HealthCheckResult {
651 check_type: HealthCheckType::Specs,
652 status,
653 message,
654 details,
655 })
656 }
657
658 fn find_git_repos_static(workspace_root: &Path) -> Result<Vec<PathBuf>> {
661 let mut repos = Vec::new();
662
663 for entry in walkdir::WalkDir::new(workspace_root)
664 .max_depth(2)
665 .into_iter()
666 .filter_entry(|e| {
667 let name = e.file_name().to_string_lossy();
668 !matches!(name.as_ref(), "target" | "node_modules" | ".cargo")
669 })
670 {
671 let entry = entry?;
672 if entry.file_type().is_dir() && entry.file_name() == ".git" {
673 if let Some(parent) = entry.path().parent() {
674 repos.push(parent.to_path_buf());
675 }
676 }
677 }
678
679 repos.sort();
680 Ok(repos)
681 }
682
683 fn get_git_status_static(repo_path: &Path) -> Result<GitStatus> {
684 let repo = git2::Repository::open(repo_path).context("Failed to open git repository")?;
685
686 let head = repo.head().context("Failed to get HEAD")?;
687 let branch = head.shorthand().unwrap_or("(detached)").to_string();
688
689 let statuses = repo.statuses(None)?;
691 let is_dirty = !statuses.is_empty();
692 let dirty_files: Vec<String> = statuses
693 .iter()
694 .filter_map(|s| s.path().map(String::from))
695 .collect();
696
697 let (ahead, behind, has_upstream) =
699 if let Ok(local_branch) = repo.find_branch(&branch, git2::BranchType::Local) {
700 if let Ok(upstream) = local_branch.upstream() {
701 let local_oid = local_branch.get().target().context("No local target")?;
702 let upstream_oid = upstream.get().target().context("No upstream target")?;
703
704 let (ahead, behind) = repo.graph_ahead_behind(local_oid, upstream_oid)?;
705 (ahead, behind, true)
706 } else {
707 (0, 0, false)
708 }
709 } else {
710 (0, 0, false)
711 };
712
713 Ok(GitStatus {
714 repo_path: repo_path.to_path_buf(),
715 branch,
716 is_dirty,
717 ahead,
718 behind,
719 has_upstream,
720 dirty_files,
721 })
722 }
723
724 fn find_packages_static(workspace_root: &Path) -> Result<Vec<PathBuf>> {
725 let mut packages = Vec::new();
726
727 for entry in walkdir::WalkDir::new(workspace_root)
728 .max_depth(2)
729 .into_iter()
730 .filter_entry(|e| {
731 let name = e.file_name().to_string_lossy();
732 !matches!(name.as_ref(), "target" | ".git" | "node_modules" | ".cargo")
733 })
734 {
735 let entry = entry?;
736 if entry.file_type().is_dir() {
737 let cargo_toml = entry.path().join("Cargo.toml");
738 if cargo_toml.exists() {
739 if let Some(name) = entry.path().file_name() {
741 if name.to_string_lossy().starts_with("embeddenator") {
742 packages.push(entry.path().to_path_buf());
743 }
744 }
745 }
746 }
747 }
748
749 packages.sort();
750 Ok(packages)
751 }
752}
753
754mod chrono {
756 pub struct Local;
757 impl Local {
758 pub fn now() -> DateTime {
759 DateTime
760 }
761 }
762 pub struct DateTime;
763 impl DateTime {
764 pub fn to_rfc3339(&self) -> String {
765 use std::time::{SystemTime, UNIX_EPOCH};
766 let now = SystemTime::now()
767 .duration_since(UNIX_EPOCH)
768 .unwrap()
769 .as_secs();
770 format!("{}", now)
771 }
772 }
773}