Skip to main content

embeddenator_workspace/
health.rs

1//! Workspace health checking utilities.
2//!
3//! Provides comprehensive health checks for the embeddenator multi-repo workspace,
4//! including git status, version alignment, test coverage, doc coverage, and spec coverage.
5
6use 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/// Types of health checks that can be performed.
17#[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/// Status of a health check.
55#[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/// Result of a single health check.
70#[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/// Git repository status.
79#[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/// Overall health report for the workspace.
91#[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    /// Check if the report contains any critical failures.
101    pub fn has_failures(&self) -> bool {
102        self.checks.iter().any(|c| c.status.is_critical())
103    }
104
105    /// Generate a Markdown report.
106    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    /// Print a colorized terminal report.
156    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
211/// Health checker for the workspace.
212pub struct HealthChecker {
213    workspace_root: PathBuf,
214}
215
216impl HealthChecker {
217    /// Create a new health checker.
218    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    /// Run all health checks in parallel.
224    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    /// Run selected health checks in parallel.
237    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                    // Continue with other checks
277                }
278                Err(e) => {
279                    eprintln!("Task panicked: {}", e);
280                }
281            }
282        }
283
284        // Determine overall status
285        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    /// Check git status across all repositories.
302    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    /// Check version alignment across packages.
386    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    /// Check test coverage by running cargo test.
443    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                        // Extract test failure summary
480                        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    /// Check documentation coverage.
516    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    /// Check spec coverage (presence of specs/ directories and documentation).
591    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                // Count spec files
609                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    // Helper methods
659
660    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        // Check for dirty files
690        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        // Check upstream
698        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                    // Only include embeddenator-* packages
740                    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
754// Note: chrono is not in dependencies yet, using a simple timestamp instead
755mod 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}