use crate::fleet_doctor_schema::{CapabilityTier, HostDoctorReport, HostOs, HostProbeStatus};
use semver::Version;
use serde::{Deserialize, Serialize};
pub const VERSION_SKEW_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CapabilityGap {
None,
Minor,
Major,
Unknown,
BinaryMissing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum UpgradeMethod {
SelfUpdate,
ManualInstaller,
Unsupported,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstallHint {
pub platform: HostOs,
pub method: UpgradeMethod,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub manual_steps: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VersionAssessment {
pub schema_version: u32,
pub host_alias: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_version: Option<String>,
pub current_repo_version: String,
pub capability_gap: CapabilityGap,
pub upgrade_needed: bool,
pub upgrade_before_repair: bool,
pub install_hint: InstallHint,
}
fn parse_version(raw: &str) -> Option<Version> {
let trimmed = raw.trim().trim_start_matches('v').trim_start_matches('V');
if let Ok(version) = Version::parse(trimmed) {
return Some(Version::new(version.major, version.minor, version.patch));
}
let mut parts = trimmed.split(['.', '-', '+', ' ']);
let major = parts.next()?.parse().ok()?;
let minor = parts.next().unwrap_or("0").parse().unwrap_or(0);
let patch = parts.next().unwrap_or("0").parse().unwrap_or(0);
Some(Version::new(major, minor, patch))
}
fn install_hint_for(platform: HostOs, method: UpgradeMethod) -> InstallHint {
match platform {
HostOs::Linux => InstallHint {
platform,
method,
command: match method {
UpgradeMethod::SelfUpdate => Some("cass self-update".to_string()),
UpgradeMethod::ManualInstaller => {
Some("curl -fsSL https://cass.sh/install.sh | bash".to_string())
}
UpgradeMethod::Unsupported => None,
},
manual_steps: vec![
"verify installed cass on PATH: `command -v cass && cass --version`".to_string(),
"re-run the official installer if self-update is unavailable".to_string(),
],
},
HostOs::MacOs => InstallHint {
platform,
method: if method == UpgradeMethod::SelfUpdate {
UpgradeMethod::ManualInstaller
} else {
method
},
command: Some("brew upgrade cass || brew install cass".to_string()),
manual_steps: vec![
"if Homebrew is unavailable, run the official macOS installer".to_string(),
"check for BSD vs GNU tool differences (rsync/coreutils) before repair".to_string(),
],
},
HostOs::Windows => InstallHint {
platform,
method: UpgradeMethod::ManualInstaller,
command: Some("scoop update cass".to_string()),
manual_steps: vec![
"if Scoop is unavailable, download the latest Windows release manually".to_string(),
],
},
HostOs::Other => InstallHint {
platform,
method: UpgradeMethod::Unsupported,
command: None,
manual_steps: vec![
"no supported installer for this platform; build from source or upgrade manually"
.to_string(),
],
},
}
}
pub fn assess_host(host: &HostDoctorReport, current_repo_version: &str) -> VersionAssessment {
let platform = host.platform.os;
let current = parse_version(current_repo_version);
let binary_absent = host.status == HostProbeStatus::CommandNotFound
|| (host.cass_version.is_none() && host.status != HostProbeStatus::Unreachable);
if binary_absent {
let method = match platform {
HostOs::Other => UpgradeMethod::Unsupported,
_ => UpgradeMethod::ManualInstaller,
};
return VersionAssessment {
schema_version: VERSION_SKEW_SCHEMA_VERSION,
host_alias: host.host_alias.clone(),
observed_version: None,
current_repo_version: current_repo_version.to_string(),
capability_gap: CapabilityGap::BinaryMissing,
upgrade_needed: true,
upgrade_before_repair: true,
install_hint: install_hint_for(platform, method),
};
}
let observed = host.cass_version.as_deref().and_then(parse_version);
let (Some(observed_ver), Some(current_ver)) = (observed, current) else {
return VersionAssessment {
schema_version: VERSION_SKEW_SCHEMA_VERSION,
host_alias: host.host_alias.clone(),
observed_version: host.cass_version.clone(),
current_repo_version: current_repo_version.to_string(),
capability_gap: CapabilityGap::Unknown,
upgrade_needed: false,
upgrade_before_repair: false,
install_hint: install_hint_for(platform, UpgradeMethod::ManualInstaller),
};
};
let minimal_tier = matches!(host.capability_tier, Some(CapabilityTier::Minimal));
let gap = if observed_ver >= current_ver {
if minimal_tier {
CapabilityGap::Minor
} else {
CapabilityGap::None
}
} else if observed_ver.major < current_ver.major
|| minimal_tier
|| (current_ver.major == 0 && observed_ver.minor < current_ver.minor)
{
CapabilityGap::Major
} else {
CapabilityGap::Minor
};
let upgrade_needed = gap != CapabilityGap::None;
let upgrade_before_repair =
gap == CapabilityGap::Major || host.status == HostProbeStatus::OldBinarySkew;
let method = if upgrade_before_repair {
UpgradeMethod::ManualInstaller
} else {
UpgradeMethod::SelfUpdate
};
VersionAssessment {
schema_version: VERSION_SKEW_SCHEMA_VERSION,
host_alias: host.host_alias.clone(),
observed_version: host.cass_version.clone(),
current_repo_version: current_repo_version.to_string(),
capability_gap: gap,
upgrade_needed,
upgrade_before_repair,
install_hint: install_hint_for(platform, method),
}
}
pub fn assess_fleet(
hosts: &[HostDoctorReport],
current_repo_version: &str,
) -> Vec<VersionAssessment> {
hosts
.iter()
.map(|host| assess_host(host, current_repo_version))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fleet_doctor_schema::{CapabilityTier, Platform};
const CURRENT: &str = "0.6.13";
fn host_with(alias: &str, version: Option<&str>, status: HostProbeStatus) -> HostDoctorReport {
let mut h = HostDoctorReport::skeleton(alias, Platform::linux_x86_64(), status, 50);
h.cass_version = version.map(str::to_string);
h
}
#[test]
fn current_binary_has_no_gap_and_no_upgrade() {
let host = host_with("local", Some("0.6.13"), HostProbeStatus::Ok);
let a = assess_host(&host, CURRENT);
assert_eq!(a.capability_gap, CapabilityGap::None);
assert!(!a.upgrade_needed);
assert!(!a.upgrade_before_repair);
}
#[test]
fn newer_binary_is_not_flagged() {
let host = host_with("ahead", Some("0.7.0"), HostProbeStatus::Ok);
let a = assess_host(&host, CURRENT);
assert_eq!(a.capability_gap, CapabilityGap::None);
assert!(!a.upgrade_needed);
}
#[test]
fn mid_version_binary_is_minor_gap_and_can_self_update() {
let host = host_with("ts2", Some("0.6.10"), HostProbeStatus::Ok);
let a = assess_host(&host, CURRENT);
assert_eq!(a.capability_gap, CapabilityGap::Minor);
assert!(a.upgrade_needed);
assert!(
!a.upgrade_before_repair,
"minor gap can be repaired without upgrading first"
);
assert_eq!(a.install_hint.method, UpgradeMethod::SelfUpdate);
assert_eq!(a.install_hint.command.as_deref(), Some("cass self-update"));
}
#[test]
fn old_major_binary_requires_upgrade_before_repair() {
let host = host_with("csd", Some("0.4.1"), HostProbeStatus::Ok);
let a = assess_host(&host, CURRENT);
assert!(a.upgrade_needed);
let mut skewed = host;
skewed.status = HostProbeStatus::OldBinarySkew;
let a2 = assess_host(&skewed, CURRENT);
assert!(
a2.upgrade_before_repair,
"old-binary-skew must upgrade before repair"
);
assert_eq!(a2.install_hint.method, UpgradeMethod::ManualInstaller);
}
#[test]
fn major_version_jump_is_major_gap() {
let host = host_with("ancient", Some("0.4.1"), HostProbeStatus::Ok);
let a = assess_host(&host, "1.0.0");
assert_eq!(a.capability_gap, CapabilityGap::Major);
assert!(a.upgrade_before_repair);
assert_eq!(a.install_hint.method, UpgradeMethod::ManualInstaller);
}
#[test]
fn cass_missing_forces_install_before_repair() {
let host = host_with("ts2", None, HostProbeStatus::CommandNotFound);
let a = assess_host(&host, CURRENT);
assert_eq!(a.capability_gap, CapabilityGap::BinaryMissing);
assert!(a.upgrade_needed);
assert!(a.upgrade_before_repair);
assert!(a.observed_version.is_none());
assert_eq!(a.install_hint.method, UpgradeMethod::ManualInstaller);
}
#[test]
fn minimal_capability_tier_forces_at_least_minor_even_at_current_version() {
let mut host = host_with("legacy-tier", Some("0.6.13"), HostProbeStatus::Ok);
host.capability_tier = Some(CapabilityTier::Minimal);
let a = assess_host(&host, CURRENT);
assert_eq!(a.capability_gap, CapabilityGap::Minor);
assert!(a.upgrade_needed);
}
#[test]
fn unsupported_platform_has_no_self_update_path() {
let mut host = HostDoctorReport::skeleton(
"exotic",
Platform {
os: HostOs::Other,
arch: "riscv64".to_string(),
path_style: crate::fleet_doctor_schema::PathStyle::Posix,
tool_notes: vec![],
},
HostProbeStatus::CommandNotFound,
10,
);
host.cass_version = None;
let a = assess_host(&host, CURRENT);
assert_eq!(a.capability_gap, CapabilityGap::BinaryMissing);
assert_eq!(a.install_hint.method, UpgradeMethod::Unsupported);
assert!(a.install_hint.command.is_none());
assert!(!a.install_hint.manual_steps.is_empty());
}
#[test]
fn macos_prefers_installer_over_self_update() {
let mut host = HostDoctorReport::skeleton(
"mac-mini-max",
Platform {
os: HostOs::MacOs,
arch: "aarch64".to_string(),
path_style: crate::fleet_doctor_schema::PathStyle::Posix,
tool_notes: vec![],
},
HostProbeStatus::Ok,
60,
);
host.cass_version = Some("0.6.10".to_string()); let a = assess_host(&host, CURRENT);
assert!(a.upgrade_needed);
assert_eq!(
a.install_hint.method,
UpgradeMethod::ManualInstaller,
"macOS should prefer the installer path even for a minor gap"
);
assert!(a.install_hint.command.as_deref().unwrap().contains("brew"));
}
#[test]
fn unparseable_version_is_unknown_without_forcing_upgrade() {
let host = host_with("weird", Some("garbage"), HostProbeStatus::Ok);
let a = assess_host(&host, CURRENT);
assert_eq!(a.capability_gap, CapabilityGap::Unknown);
assert!(!a.upgrade_before_repair);
assert_eq!(a.observed_version.as_deref(), Some("garbage"));
}
#[test]
fn parse_version_tolerates_v_prefix_and_suffix() {
assert_eq!(parse_version("v0.6.13"), Some(Version::new(0, 6, 13)));
assert_eq!(parse_version("0.6.10-dirty"), Some(Version::new(0, 6, 10)));
assert_eq!(parse_version("1.2"), Some(Version::new(1, 2, 0)));
assert!(parse_version("garbage").is_none());
}
#[test]
fn assessment_serializes_with_stable_fields() {
let host = host_with("ts2", Some("0.6.10"), HostProbeStatus::Ok);
let a = assess_host(&host, CURRENT);
let value = serde_json::to_value(&a).unwrap();
assert_eq!(value["host_alias"], "ts2");
assert_eq!(value["observed_version"], "0.6.10");
assert_eq!(value["current_repo_version"], "0.6.13");
assert_eq!(value["capability_gap"], "minor");
assert_eq!(value["upgrade_needed"], true);
assert_eq!(value["upgrade_before_repair"], false);
assert_eq!(value["install_hint"]["method"], "self-update");
let back: VersionAssessment = serde_json::from_value(value).unwrap();
assert_eq!(back, a);
}
#[test]
fn assess_fleet_maps_every_host() {
let hosts = vec![
host_with("local", Some("0.6.13"), HostProbeStatus::Ok),
host_with("ts2", Some("0.6.10"), HostProbeStatus::Ok),
host_with("csd", Some("0.4.1"), HostProbeStatus::OldBinarySkew),
host_with("gone", None, HostProbeStatus::CommandNotFound),
];
let assessments = assess_fleet(&hosts, CURRENT);
assert_eq!(assessments.len(), 4);
assert_eq!(assessments[0].capability_gap, CapabilityGap::None);
assert_eq!(assessments[1].capability_gap, CapabilityGap::Minor);
assert!(assessments[2].upgrade_before_repair);
assert_eq!(assessments[3].capability_gap, CapabilityGap::BinaryMissing);
}
}