1use crate::stack::crates_io::{CratesIoClient, MockCratesIoClient};
8use crate::stack::graph::DependencyGraph;
9use crate::stack::types::*;
10use anyhow::Result;
11use std::path::Path;
12
13pub struct StackChecker {
15 graph: DependencyGraph,
17
18 verify_published: bool,
20
21 strict: bool,
23}
24
25impl StackChecker {
26 #[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 pub fn with_graph(graph: DependencyGraph) -> Self {
35 Self { graph, verify_published: false, strict: false }
36 }
37
38 pub fn verify_published(mut self, verify: bool) -> Self {
40 self.verify_published = verify;
41 self
42 }
43
44 pub fn strict(mut self, strict: bool) -> Self {
46 self.strict = strict;
47 self
48 }
49
50 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 #[cfg(feature = "native")]
57 pub async fn check(&mut self, client: &mut CratesIoClient) -> Result<StackHealthReport> {
58 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 fn run_checks<F>(&mut self, get_crates_io_version: F) -> Result<StackHealthReport>
74 where
75 F: Fn(&str) -> Option<semver::Version>,
76 {
77 let path_deps = self.graph.find_path_dependencies();
83
84 let conflicts = self.graph.detect_conflicts();
86
87 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 info.crates_io_version = get_crates_io_version(&info.name);
95
96 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 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 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 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 info.status = Self::determine_status(&info.issues, self.strict);
162
163 crates.push(info);
164 }
165
166 crates.sort_by(|a, b| a.name.cmp(&b.name));
168
169 Ok(StackHealthReport::new(crates, conflicts))
170 }
171
172 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 pub fn release_order_for(&self, crate_name: &str) -> Result<Vec<String>> {
192 self.graph.release_order_for(crate_name)
193 }
194
195 pub fn topological_order(&self) -> Result<Vec<String>> {
197 self.graph.topological_order()
198 }
199
200 pub fn crate_count(&self) -> usize {
202 self.graph.crate_count()
203 }
204
205 pub fn get_crate(&self, name: &str) -> Option<&CrateInfo> {
207 self.graph.get_crate(name)
208 }
209
210 pub fn find_path_dependencies(&self) -> Vec<crate::stack::graph::PathDependencyIssue> {
212 self.graph.find_path_dependencies()
213 }
214}
215
216fn 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
234pub 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 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
298pub 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 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
352pub 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;