use std::collections::BTreeMap;
use serde::Deserialize;
use super::contract::Contract;
use crate::tool::{HealthReport, HealthStatus};
pub(crate) fn check_cargo_lock(
contract: &Contract,
lockfile_path: &std::path::Path,
report: &mut HealthReport,
) {
let packages = match read_lockfile(lockfile_path) {
Ok(packages) => packages,
Err(error) => {
report.push(
"cargo.lock",
HealthStatus::Warning,
format!("cannot read {}: {error}", lockfile_path.display()),
);
return;
}
};
for (name, component) in &contract.components {
let lockfile_name = name.replace('-', "_");
if let Some(actual) = packages.get(&lockfile_name) {
for entry in &contract.incompatible.entries {
if entry.component == *name && version_matches(&entry.version, actual) {
report.push(
name,
HealthStatus::Error,
format!("{actual} is KNOWN INCOMPATIBLE: {}", entry.reason),
);
}
}
compare_version(report, name, &component.version, actual);
}
}
}
pub(crate) fn check_frontend(
contract: &Contract,
package_json_path: &std::path::Path,
report: &mut HealthReport,
) {
let manifest = match read_package_json(package_json_path) {
Ok(manifest) => manifest,
Err(error) => {
report.push(
"frontend",
HealthStatus::Warning,
format!("cannot read {}: {error}", package_json_path.display()),
);
return;
}
};
let checks: &[(&str, &str)] = &[
("vite", "vite"),
("typescript", "typescript"),
("react", "react"),
("react_dom", "react-dom"),
("vue", "vue"),
("inertia_react", "@inertiajs/react"),
("inertia_vue", "@inertiajs/vue3"),
];
for (contract_name, npm_name) in checks {
let Some(component) = contract.components.get(*contract_name) else {
continue;
};
let actual = manifest
.dependencies
.get(*npm_name)
.or_else(|| manifest.dev_dependencies.get(*npm_name));
if let Some(actual) = actual {
compare_version(report, contract_name, &component.version, actual);
}
}
}
fn compare_version(report: &mut HealthReport, name: &str, certified: &str, actual: &str) {
if version_matches(certified, actual) {
report.push(name, HealthStatus::Ok, format!("{actual} (certified)"));
} else {
report.push(
name,
HealthStatus::Unverified,
format!("{actual} (certified: {certified}) — UNVERIFIED OVERRIDE"),
);
}
}
fn version_matches(certified: &str, actual: &str) -> bool {
if certified.contains('<') || certified.contains('>') {
return range_matches(certified, actual);
}
let clean_actual = actual.trim_start_matches(['^', '~', '=', 'v', ' ']);
let clean_certified = certified.trim_start_matches('v');
semver_eq(clean_certified, clean_actual)
}
fn range_matches(range: &str, actual: &str) -> bool {
let clean = actual.trim_start_matches(['^', '~', '=', 'v', ' ']);
let actual_parts: Vec<u64> = clean
.split(['.', '-'])
.filter_map(|p| p.parse().ok())
.collect();
for bound in range.split(',') {
let bound = bound.trim();
if let Some(min) = bound.strip_prefix(">=") {
let min_parts: Vec<u64> = min
.trim()
.split('.')
.filter_map(|p| p.parse().ok())
.collect();
if !gte(&actual_parts, &min_parts) {
return false;
}
} else if let Some(max) = bound.strip_prefix("<") {
let max_parts: Vec<u64> = max
.trim()
.split('.')
.filter_map(|p| p.parse().ok())
.collect();
if gte(&actual_parts, &max_parts) {
return false;
}
}
}
true
}
fn gte(a: &[u64], b: &[u64]) -> bool {
for i in 0..b.len().max(a.len()) {
let av = a.get(i).copied().unwrap_or(0);
let bv = b.get(i).copied().unwrap_or(0);
if av != bv {
return av > bv;
}
}
true
}
fn semver_eq(a: &str, b: &str) -> bool {
let pa: Vec<u64> = a.split(['.', '-']).filter_map(|p| p.parse().ok()).collect();
let pb: Vec<u64> = b.split(['.', '-']).filter_map(|p| p.parse().ok()).collect();
gte(&pa, &pb) && gte(&pb, &pa)
}
#[derive(Debug, Deserialize)]
struct Lockfile {
package: Vec<LockfilePackage>,
}
#[derive(Debug, Deserialize)]
struct LockfilePackage {
name: String,
version: String,
}
fn read_lockfile(
path: &std::path::Path,
) -> Result<BTreeMap<String, String>, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let lockfile: Lockfile = toml::from_str(&content)?;
let mut map = BTreeMap::new();
for package in lockfile.package {
map.entry(package.name).or_insert(package.version);
}
Ok(map)
}
#[derive(Debug, Deserialize)]
struct PackageJson {
#[serde(default)]
dependencies: BTreeMap<String, String>,
#[serde(default, rename = "devDependencies")]
dev_dependencies: BTreeMap<String, String>,
}
fn read_package_json(path: &std::path::Path) -> Result<PackageJson, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
Ok(serde_json::from_str(&content)?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stack::contract::load;
#[test]
fn exact_version_match() {
assert!(version_matches("0.8.9", "0.8.9"));
assert!(version_matches("0.8.9", "^0.8.9"));
assert!(version_matches("0.8.9", "v0.8.9"));
assert!(!version_matches("0.8.9", "0.8.8"));
}
#[test]
fn range_match() {
assert!(version_matches(">=24.19.0,<25", "24.19.0"));
assert!(version_matches(">=24.19.0,<25", "24.20.0"));
assert!(!version_matches(">=24.19.0,<25", "25.0.0"));
assert!(!version_matches(">=24.19.0,<25", "24.18.0"));
}
#[test]
fn unverified_override_is_reported_not_error() {
let mut report = HealthReport::default();
compare_version(&mut report, "axum", "0.8.9", "0.8.8");
assert_eq!(report.checks[0].status, HealthStatus::Unverified);
assert!(report.checks[0].detail.contains("UNVERIFIED OVERRIDE"));
assert!(!report.has_error());
}
#[test]
fn certified_match_is_ok() {
let mut report = HealthReport::default();
compare_version(&mut report, "axum", "0.8.9", "0.8.9");
assert_eq!(report.checks[0].status, HealthStatus::Ok);
assert!(!report.has_error());
}
const CERTIFIED_LOCKFILE: &str = include_str!("fixtures/certified.lock");
const OVERRIDDEN_LOCKFILE: &str = include_str!("fixtures/overridden.lock");
const CERTIFIED_PACKAGE_JSON: &str = include_str!("fixtures/package.json");
const OVERRIDDEN_PACKAGE_JSON: &str = include_str!("fixtures/package_overridden.json");
fn write_temp(content: &str, name: &str) -> std::path::PathBuf {
let path =
std::env::temp_dir().join(format!("arcature-upgrade-{name}-{}", std::process::id()));
std::fs::write(&path, content).expect("temp file should be written");
path
}
#[test]
fn certified_lockfile_reports_all_pass() {
let contract = load();
let lockfile = write_temp(CERTIFIED_LOCKFILE, "certified.lock");
let mut report = HealthReport::default();
check_cargo_lock(&contract, &lockfile, &mut report);
let _ = std::fs::remove_file(&lockfile);
let unverified: Vec<_> = report
.checks
.iter()
.filter(|c| c.detail.contains("UNVERIFIED OVERRIDE"))
.collect();
assert!(
unverified.is_empty(),
"certified lockfile has unverified: {:?}",
unverified.iter().map(|c| &c.name).collect::<Vec<_>>()
);
assert!(
report
.checks
.iter()
.any(|c| c.name == "axum" && c.detail.contains("certified"))
);
}
#[test]
fn overridden_lockfile_reports_unverified_override() {
let contract = load();
let lockfile = write_temp(OVERRIDDEN_LOCKFILE, "overridden.lock");
let mut report = HealthReport::default();
check_cargo_lock(&contract, &lockfile, &mut report);
let _ = std::fs::remove_file(&lockfile);
let axum = report
.checks
.iter()
.find(|c| c.name == "axum")
.expect("axum should be in the report");
assert!(
axum.detail.contains("UNVERIFIED OVERRIDE"),
"overridden axum should be UNVERIFIED: {}",
axum.detail
);
assert!(!report.has_error(), "override is not an error");
}
#[test]
fn certified_frontend_reports_all_pass() {
let contract = load();
let package_json = write_temp(CERTIFIED_PACKAGE_JSON, "package.json");
let mut report = HealthReport::default();
check_frontend(&contract, &package_json, &mut report);
let _ = std::fs::remove_file(&package_json);
let unverified: Vec<_> = report
.checks
.iter()
.filter(|c| c.detail.contains("UNVERIFIED OVERRIDE"))
.collect();
assert!(
unverified.is_empty(),
"certified frontend has unverified: {:?}",
unverified.iter().map(|c| &c.name).collect::<Vec<_>>()
);
assert!(
report
.checks
.iter()
.any(|c| c.name == "vite" && c.detail.contains("certified"))
);
}
#[test]
fn overridden_frontend_reports_unverified_override() {
let contract = load();
let package_json = write_temp(OVERRIDDEN_PACKAGE_JSON, "package_overridden.json");
let mut report = HealthReport::default();
check_frontend(&contract, &package_json, &mut report);
let _ = std::fs::remove_file(&package_json);
let vite = report
.checks
.iter()
.find(|c| c.name == "vite")
.expect("vite should be in the report");
assert!(
vite.detail.contains("UNVERIFIED OVERRIDE"),
"overridden vite should be UNVERIFIED: {}",
vite.detail
);
assert!(!report.has_error(), "override is not an error");
}
#[test]
fn contract_services_matrix_is_complete() {
let contract = load();
assert_eq!(
contract.services["postgresql"].certified_versions,
vec!["15.13", "16.9", "17.5", "18.4"]
);
assert!(
contract.services["valkey"]
.certified_versions
.contains(&"8.1.2".to_owned())
);
assert!(
contract.services["valkey"]
.certified_versions
.contains(&"9.1.1".to_owned())
);
}
}