Skip to main content

batuta/stack/
checker.rs

1//! Stack Health Checker
2//!
3//! Implements the `batuta stack check` command functionality.
4//! Analyzes the PAIML stack for dependency issues, version conflicts,
5//! and path dependencies that should be crates.io versions.
6
7use crate::stack::crates_io::{CratesIoClient, MockCratesIoClient};
8use crate::stack::graph::DependencyGraph;
9use crate::stack::types::*;
10use anyhow::Result;
11use std::path::Path;
12
13/// Stack health checker
14pub struct StackChecker {
15    /// Dependency graph
16    graph: DependencyGraph,
17
18    /// Whether to verify against crates.io
19    verify_published: bool,
20
21    /// Strict mode (fail on warnings)
22    strict: bool,
23}
24
25impl StackChecker {
26    /// Create a new stack checker from a workspace path
27    #[cfg(feature = "native")]
28    pub fn from_workspace(workspace_path: &Path) -> Result<Self> {
29        let graph = DependencyGraph::from_workspace(workspace_path)?;
30        Ok(Self { graph, verify_published: false, strict: false })
31    }
32
33    /// Create a stack checker with an existing graph (for testing)
34    pub fn with_graph(graph: DependencyGraph) -> Self {
35        Self { graph, verify_published: false, strict: false }
36    }
37
38    /// Enable crates.io verification
39    pub fn verify_published(mut self, verify: bool) -> Self {
40        self.verify_published = verify;
41        self
42    }
43
44    /// Enable strict mode
45    pub fn strict(mut self, strict: bool) -> Self {
46        self.strict = strict;
47        self
48    }
49
50    /// Run health check with mock crates.io client (for testing)
51    pub fn check_with_mock(&mut self, mock: &MockCratesIoClient) -> Result<StackHealthReport> {
52        self.run_checks(|name| mock.get_latest_version(name).ok())
53    }
54
55    /// Run health check with real crates.io client
56    #[cfg(feature = "native")]
57    pub async fn check(&mut self, client: &mut CratesIoClient) -> Result<StackHealthReport> {
58        // First, fetch all crates.io versions
59        let mut crates_io_versions = std::collections::HashMap::new();
60
61        if self.verify_published {
62            for crate_info in self.graph.all_crates() {
63                if let Ok(version) = client.get_latest_version(&crate_info.name).await {
64                    crates_io_versions.insert(crate_info.name.clone(), version);
65                }
66            }
67        }
68
69        self.run_checks(|name| crates_io_versions.get(name).cloned())
70    }
71
72    /// Internal check implementation
73    fn run_checks<F>(&mut self, get_crates_io_version: F) -> Result<StackHealthReport>
74    where
75        F: Fn(&str) -> Option<semver::Version>,
76    {
77        // Check for cycles first
78        // Note: Cycle detection is done at the graph level
79        // If cycles exist, topological_order() will fail
80
81        // Find path dependencies
82        let path_deps = self.graph.find_path_dependencies();
83
84        // Detect version conflicts
85        let conflicts = self.graph.detect_conflicts();
86
87        // Update crate statuses and issues
88        let mut crates: Vec<CrateInfo> = Vec::new();
89
90        for crate_info in self.graph.all_crates() {
91            let mut info = crate_info.clone();
92
93            // Update crates.io version
94            info.crates_io_version = get_crates_io_version(&info.name);
95
96            // Check for path dependencies
97            for path_dep in &path_deps {
98                if path_dep.crate_name == info.name {
99                    let suggestion = info
100                        .crates_io_version
101                        .as_ref()
102                        .map(|v| format!("{} = \"{}\"", path_dep.dependency, v));
103
104                    let mut issue = CrateIssue::new(
105                        IssueSeverity::Error,
106                        IssueType::PathDependency,
107                        format!(
108                            "Path dependency '{}' should be a crates.io version",
109                            path_dep.dependency
110                        ),
111                    );
112
113                    if let Some(sug) = suggestion {
114                        issue = issue.with_suggestion(sug);
115                    }
116
117                    info.issues.push(issue);
118                }
119            }
120
121            // Check for version conflicts
122            for conflict in &conflicts {
123                let is_involved = conflict.usages.iter().any(|u| u.crate_name == info.name);
124
125                if is_involved {
126                    info.issues.push(CrateIssue::new(
127                        IssueSeverity::Warning,
128                        IssueType::VersionConflict,
129                        format!(
130                            "Version conflict for '{}': different versions required across stack",
131                            conflict.dependency
132                        ),
133                    ));
134                }
135            }
136
137            // Check if not published
138            if self.verify_published && info.crates_io_version.is_none() {
139                info.issues.push(CrateIssue::new(
140                    IssueSeverity::Info,
141                    IssueType::NotPublished,
142                    format!("Crate '{}' is not published to crates.io", info.name),
143                ));
144            }
145
146            // Check if version is behind
147            if let Some(ref remote) = info.crates_io_version {
148                if info.local_version < *remote {
149                    info.issues.push(CrateIssue::new(
150                        IssueSeverity::Warning,
151                        IssueType::VersionBehind,
152                        format!(
153                            "Local version {} is behind crates.io version {}",
154                            info.local_version, remote
155                        ),
156                    ));
157                }
158            }
159
160            // Determine status based on issues
161            info.status = Self::determine_status(&info.issues, self.strict);
162
163            crates.push(info);
164        }
165
166        // Sort crates by name for consistent output
167        crates.sort_by(|a, b| a.name.cmp(&b.name));
168
169        Ok(StackHealthReport::new(crates, conflicts))
170    }
171
172    /// Determine crate status based on issues
173    fn determine_status(issues: &[CrateIssue], strict: bool) -> CrateStatus {
174        let has_errors = issues.iter().any(|i| i.severity == IssueSeverity::Error);
175        let has_warnings = issues.iter().any(|i| i.severity == IssueSeverity::Warning);
176
177        if has_errors {
178            CrateStatus::Error
179        } else if has_warnings {
180            if strict {
181                CrateStatus::Error
182            } else {
183                CrateStatus::Warning
184            }
185        } else {
186            CrateStatus::Healthy
187        }
188    }
189
190    /// Get the release order for a specific crate
191    pub fn release_order_for(&self, crate_name: &str) -> Result<Vec<String>> {
192        self.graph.release_order_for(crate_name)
193    }
194
195    /// Get topological order for all crates
196    pub fn topological_order(&self) -> Result<Vec<String>> {
197        self.graph.topological_order()
198    }
199
200    /// Get number of crates in the stack
201    pub fn crate_count(&self) -> usize {
202        self.graph.crate_count()
203    }
204
205    /// Get crate info by name
206    pub fn get_crate(&self, name: &str) -> Option<&CrateInfo> {
207        self.graph.get_crate(name)
208    }
209
210    /// Get all path dependencies in the graph
211    pub fn find_path_dependencies(&self) -> Vec<crate::stack::graph::PathDependencyIssue> {
212        self.graph.find_path_dependencies()
213    }
214}
215
216/// Format version conflicts as text.
217fn format_conflicts_text(output: &mut String, conflicts: &[VersionConflict]) {
218    output.push_str("Version Conflicts:\n");
219    output.push_str(&"─".repeat(40));
220    output.push('\n');
221    for conflict in conflicts {
222        output.push_str(&format!("  {} conflict:\n", conflict.dependency));
223        for usage in &conflict.usages {
224            output
225                .push_str(&format!("    - {} requires {}\n", usage.crate_name, usage.version_req));
226        }
227        if let Some(ref rec) = conflict.recommendation {
228            output.push_str(&format!("    Recommendation: {}\n", rec));
229        }
230        output.push('\n');
231    }
232}
233
234/// Format a health report as text
235pub fn format_report_text(report: &StackHealthReport) -> String {
236    let mut output = String::new();
237
238    output.push_str("šŸ” PAIML Stack Health Check\n");
239    output.push_str(&"═".repeat(60));
240    output.push_str("\n\n");
241
242    for crate_info in &report.crates {
243        let status_icon = match crate_info.status {
244            CrateStatus::Healthy => "āœ…",
245            CrateStatus::Warning => "āš ļø ",
246            CrateStatus::Error => "āŒ",
247            CrateStatus::Unknown => "ā“",
248        };
249
250        let crates_io_str = match &crate_info.crates_io_version {
251            Some(v) => format!("(crates.io: {})", v),
252            None => "(not published)".to_string(),
253        };
254
255        output.push_str(&format!(
256            "{} {} v{} {}\n",
257            status_icon, crate_info.name, crate_info.local_version, crates_io_str
258        ));
259
260        for issue in &crate_info.issues {
261            let severity_prefix = match issue.severity {
262                IssueSeverity::Error => "  āœ—",
263                IssueSeverity::Warning => "  ⚠",
264                IssueSeverity::Info => "  ℹ",
265            };
266
267            output.push_str(&format!("{}  {}\n", severity_prefix, issue.message));
268
269            if let Some(ref suggestion) = issue.suggestion {
270                output.push_str(&format!("      → {}\n", suggestion));
271            }
272        }
273
274        output.push('\n');
275    }
276
277    if !report.conflicts.is_empty() {
278        format_conflicts_text(&mut output, &report.conflicts);
279    }
280
281    // Summary
282    output.push_str(&"─".repeat(60));
283    output.push('\n');
284    output.push_str("Summary:\n");
285    output.push_str(&format!("  Total crates: {}\n", report.summary.total_crates));
286    output.push_str(&format!("  Healthy: {}\n", report.summary.healthy_count));
287    output.push_str(&format!("  Warnings: {}\n", report.summary.warning_count));
288    output.push_str(&format!("  Errors: {}\n", report.summary.error_count));
289
290    if report.summary.path_dependency_count > 0 {
291        output
292            .push_str(&format!("  Path dependencies: {}\n", report.summary.path_dependency_count));
293    }
294
295    output
296}
297
298/// Format a health report as Markdown
299pub fn format_report_markdown(report: &StackHealthReport) -> String {
300    let mut output = String::new();
301
302    output.push_str("# PAIML Stack Health Report\n\n");
303
304    output.push_str("## Crates\n\n");
305    output.push_str("| Status | Crate | Version | Crates.io |\n");
306    output.push_str("|--------|-------|---------|----------|\n");
307
308    for crate_info in &report.crates {
309        let status_icon = match crate_info.status {
310            CrateStatus::Healthy => "āœ…",
311            CrateStatus::Warning => "āš ļø",
312            CrateStatus::Error => "āŒ",
313            CrateStatus::Unknown => "ā“",
314        };
315
316        let crates_io_str = match &crate_info.crates_io_version {
317            Some(v) => v.to_string(),
318            None => "not published".to_string(),
319        };
320
321        output.push_str(&format!(
322            "| {} | {} | {} | {} |\n",
323            status_icon, crate_info.name, crate_info.local_version, crates_io_str
324        ));
325
326        // Add issues as sub-items
327        for issue in &crate_info.issues {
328            let icon = match issue.severity {
329                IssueSeverity::Error => "āŒ",
330                IssueSeverity::Warning => "āš ļø",
331                IssueSeverity::Info => "ā„¹ļø",
332            };
333            output.push_str(&format!("| | | {} {} | |\n", icon, issue.message));
334        }
335    }
336
337    output.push_str("\n## Summary\n\n");
338    output.push_str(&format!("- **Total crates**: {}\n", report.summary.total_crates));
339    output.push_str(&format!("- **Healthy**: {}\n", report.summary.healthy_count));
340    output.push_str(&format!("- **Warnings**: {}\n", report.summary.warning_count));
341    output.push_str(&format!("- **Errors**: {}\n", report.summary.error_count));
342
343    if report.is_healthy() {
344        output.push_str("\nāœ… **All crates are healthy**\n");
345    } else {
346        output.push_str("\nāš ļø **Some crates need attention**\n");
347    }
348
349    output
350}
351
352/// Format a health report as JSON
353pub fn format_report_json(report: &StackHealthReport) -> Result<String> {
354    serde_json::to_string_pretty(report)
355        .map_err(|e| anyhow::anyhow!("JSON serialization error: {}", e))
356}
357
358#[cfg(test)]
359#[path = "checker_tests.rs"]
360mod tests;