use crate::stack::crates_io::{CratesIoClient, MockCratesIoClient};
use crate::stack::graph::DependencyGraph;
use crate::stack::types::*;
use anyhow::Result;
use std::path::Path;
pub struct StackChecker {
graph: DependencyGraph,
verify_published: bool,
strict: bool,
}
impl StackChecker {
#[cfg(feature = "native")]
pub fn from_workspace(workspace_path: &Path) -> Result<Self> {
let graph = DependencyGraph::from_workspace(workspace_path)?;
Ok(Self { graph, verify_published: false, strict: false })
}
pub fn with_graph(graph: DependencyGraph) -> Self {
Self { graph, verify_published: false, strict: false }
}
pub fn verify_published(mut self, verify: bool) -> Self {
self.verify_published = verify;
self
}
pub fn strict(mut self, strict: bool) -> Self {
self.strict = strict;
self
}
pub fn check_with_mock(&mut self, mock: &MockCratesIoClient) -> Result<StackHealthReport> {
self.run_checks(|name| mock.get_latest_version(name).ok())
}
#[cfg(feature = "native")]
pub async fn check(&mut self, client: &mut CratesIoClient) -> Result<StackHealthReport> {
let mut crates_io_versions = std::collections::HashMap::new();
if self.verify_published {
for crate_info in self.graph.all_crates() {
if let Ok(version) = client.get_latest_version(&crate_info.name).await {
crates_io_versions.insert(crate_info.name.clone(), version);
}
}
}
self.run_checks(|name| crates_io_versions.get(name).cloned())
}
fn run_checks<F>(&mut self, get_crates_io_version: F) -> Result<StackHealthReport>
where
F: Fn(&str) -> Option<semver::Version>,
{
let path_deps = self.graph.find_path_dependencies();
let conflicts = self.graph.detect_conflicts();
let mut crates: Vec<CrateInfo> = Vec::new();
for crate_info in self.graph.all_crates() {
let mut info = crate_info.clone();
info.crates_io_version = get_crates_io_version(&info.name);
for path_dep in &path_deps {
if path_dep.crate_name == info.name {
let suggestion = info
.crates_io_version
.as_ref()
.map(|v| format!("{} = \"{}\"", path_dep.dependency, v));
let mut issue = CrateIssue::new(
IssueSeverity::Error,
IssueType::PathDependency,
format!(
"Path dependency '{}' should be a crates.io version",
path_dep.dependency
),
);
if let Some(sug) = suggestion {
issue = issue.with_suggestion(sug);
}
info.issues.push(issue);
}
}
for conflict in &conflicts {
let is_involved = conflict.usages.iter().any(|u| u.crate_name == info.name);
if is_involved {
info.issues.push(CrateIssue::new(
IssueSeverity::Warning,
IssueType::VersionConflict,
format!(
"Version conflict for '{}': different versions required across stack",
conflict.dependency
),
));
}
}
if self.verify_published && info.crates_io_version.is_none() {
info.issues.push(CrateIssue::new(
IssueSeverity::Info,
IssueType::NotPublished,
format!("Crate '{}' is not published to crates.io", info.name),
));
}
if let Some(ref remote) = info.crates_io_version {
if info.local_version < *remote {
info.issues.push(CrateIssue::new(
IssueSeverity::Warning,
IssueType::VersionBehind,
format!(
"Local version {} is behind crates.io version {}",
info.local_version, remote
),
));
}
}
info.status = Self::determine_status(&info.issues, self.strict);
crates.push(info);
}
crates.sort_by(|a, b| a.name.cmp(&b.name));
Ok(StackHealthReport::new(crates, conflicts))
}
fn determine_status(issues: &[CrateIssue], strict: bool) -> CrateStatus {
let has_errors = issues.iter().any(|i| i.severity == IssueSeverity::Error);
let has_warnings = issues.iter().any(|i| i.severity == IssueSeverity::Warning);
if has_errors {
CrateStatus::Error
} else if has_warnings {
if strict {
CrateStatus::Error
} else {
CrateStatus::Warning
}
} else {
CrateStatus::Healthy
}
}
pub fn release_order_for(&self, crate_name: &str) -> Result<Vec<String>> {
self.graph.release_order_for(crate_name)
}
pub fn topological_order(&self) -> Result<Vec<String>> {
self.graph.topological_order()
}
pub fn crate_count(&self) -> usize {
self.graph.crate_count()
}
pub fn get_crate(&self, name: &str) -> Option<&CrateInfo> {
self.graph.get_crate(name)
}
pub fn find_path_dependencies(&self) -> Vec<crate::stack::graph::PathDependencyIssue> {
self.graph.find_path_dependencies()
}
}
fn format_conflicts_text(output: &mut String, conflicts: &[VersionConflict]) {
output.push_str("Version Conflicts:\n");
output.push_str(&"─".repeat(40));
output.push('\n');
for conflict in conflicts {
output.push_str(&format!(" {} conflict:\n", conflict.dependency));
for usage in &conflict.usages {
output
.push_str(&format!(" - {} requires {}\n", usage.crate_name, usage.version_req));
}
if let Some(ref rec) = conflict.recommendation {
output.push_str(&format!(" Recommendation: {}\n", rec));
}
output.push('\n');
}
}
pub fn format_report_text(report: &StackHealthReport) -> String {
let mut output = String::new();
output.push_str("🔍 PAIML Stack Health Check\n");
output.push_str(&"═".repeat(60));
output.push_str("\n\n");
for crate_info in &report.crates {
let status_icon = match crate_info.status {
CrateStatus::Healthy => "✅",
CrateStatus::Warning => "⚠️ ",
CrateStatus::Error => "❌",
CrateStatus::Unknown => "❓",
};
let crates_io_str = match &crate_info.crates_io_version {
Some(v) => format!("(crates.io: {})", v),
None => "(not published)".to_string(),
};
output.push_str(&format!(
"{} {} v{} {}\n",
status_icon, crate_info.name, crate_info.local_version, crates_io_str
));
for issue in &crate_info.issues {
let severity_prefix = match issue.severity {
IssueSeverity::Error => " ✗",
IssueSeverity::Warning => " ⚠",
IssueSeverity::Info => " ℹ",
};
output.push_str(&format!("{} {}\n", severity_prefix, issue.message));
if let Some(ref suggestion) = issue.suggestion {
output.push_str(&format!(" → {}\n", suggestion));
}
}
output.push('\n');
}
if !report.conflicts.is_empty() {
format_conflicts_text(&mut output, &report.conflicts);
}
output.push_str(&"─".repeat(60));
output.push('\n');
output.push_str("Summary:\n");
output.push_str(&format!(" Total crates: {}\n", report.summary.total_crates));
output.push_str(&format!(" Healthy: {}\n", report.summary.healthy_count));
output.push_str(&format!(" Warnings: {}\n", report.summary.warning_count));
output.push_str(&format!(" Errors: {}\n", report.summary.error_count));
if report.summary.path_dependency_count > 0 {
output
.push_str(&format!(" Path dependencies: {}\n", report.summary.path_dependency_count));
}
output
}
pub fn format_report_markdown(report: &StackHealthReport) -> String {
let mut output = String::new();
output.push_str("# PAIML Stack Health Report\n\n");
output.push_str("## Crates\n\n");
output.push_str("| Status | Crate | Version | Crates.io |\n");
output.push_str("|--------|-------|---------|----------|\n");
for crate_info in &report.crates {
let status_icon = match crate_info.status {
CrateStatus::Healthy => "✅",
CrateStatus::Warning => "⚠️",
CrateStatus::Error => "❌",
CrateStatus::Unknown => "❓",
};
let crates_io_str = match &crate_info.crates_io_version {
Some(v) => v.to_string(),
None => "not published".to_string(),
};
output.push_str(&format!(
"| {} | {} | {} | {} |\n",
status_icon, crate_info.name, crate_info.local_version, crates_io_str
));
for issue in &crate_info.issues {
let icon = match issue.severity {
IssueSeverity::Error => "❌",
IssueSeverity::Warning => "⚠️",
IssueSeverity::Info => "ℹ️",
};
output.push_str(&format!("| | | {} {} | |\n", icon, issue.message));
}
}
output.push_str("\n## Summary\n\n");
output.push_str(&format!("- **Total crates**: {}\n", report.summary.total_crates));
output.push_str(&format!("- **Healthy**: {}\n", report.summary.healthy_count));
output.push_str(&format!("- **Warnings**: {}\n", report.summary.warning_count));
output.push_str(&format!("- **Errors**: {}\n", report.summary.error_count));
if report.is_healthy() {
output.push_str("\n✅ **All crates are healthy**\n");
} else {
output.push_str("\n⚠️ **Some crates need attention**\n");
}
output
}
pub fn format_report_json(report: &StackHealthReport) -> Result<String> {
serde_json::to_string_pretty(report)
.map_err(|e| anyhow::anyhow!("JSON serialization error: {}", e))
}
#[cfg(test)]
#[path = "checker_tests.rs"]
mod tests;