use super::{LockError, LockFile, LockedTool};
use crate::tools::common::has;
#[derive(Debug, Clone)]
pub struct ToolVerification {
pub name: String,
pub status: VerificationStatus,
pub locked_version: String,
pub installed_version: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum VerificationStatus {
Match,
VersionMismatch,
NotInstalled,
#[allow(dead_code)] NotLocked,
Unknown,
}
impl std::fmt::Display for VerificationStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VerificationStatus::Match => write!(f, "match"),
VerificationStatus::VersionMismatch => write!(f, "version mismatch"),
VerificationStatus::NotInstalled => write!(f, "not installed"),
VerificationStatus::NotLocked => write!(f, "not locked"),
VerificationStatus::Unknown => write!(f, "unknown"),
}
}
}
#[derive(Debug)]
pub struct VerificationResult {
pub tools: Vec<ToolVerification>,
pub all_match: bool,
pub matched: usize,
pub mismatched: usize,
pub missing: usize,
}
impl VerificationResult {
pub fn new() -> Self {
Self {
tools: Vec::new(),
all_match: true,
matched: 0,
mismatched: 0,
missing: 0,
}
}
pub fn add(&mut self, verification: ToolVerification) {
match verification.status {
VerificationStatus::Match => self.matched += 1,
VerificationStatus::VersionMismatch => {
self.mismatched += 1;
self.all_match = false;
}
VerificationStatus::NotInstalled => {
self.missing += 1;
self.all_match = false;
}
VerificationStatus::NotLocked | VerificationStatus::Unknown => {
self.all_match = false;
}
}
self.tools.push(verification);
}
#[allow(dead_code)] pub fn mismatches(&self) -> Vec<&ToolVerification> {
self.tools
.iter()
.filter(|t| t.status == VerificationStatus::VersionMismatch)
.collect()
}
#[allow(dead_code)] pub fn missing_tools(&self) -> Vec<&ToolVerification> {
self.tools
.iter()
.filter(|t| t.status == VerificationStatus::NotInstalled)
.collect()
}
}
impl Default for VerificationResult {
fn default() -> Self {
Self::new()
}
}
pub fn verify_lock(lock: &LockFile, platform: &str) -> VerificationResult {
let mut result = VerificationResult::new();
let legacy_count: usize = lock
.tools
.values()
.chain(lock.platforms.values().flat_map(|m| m.values()))
.filter(|t| {
t.checksum
.as_deref()
.is_some_and(super::generate::is_legacy_checksum)
})
.count();
if legacy_count > 0 {
tracing::warn!(
event = "lock.legacy_checksum_detected",
count = legacy_count,
"lock file contains legacy short-hash checksums; regenerate with `jarvy lock generate`"
);
}
for (name, locked_tool) in &lock.tools {
let tool = lock.get_tool(name, platform).unwrap_or(locked_tool);
let verification = verify_tool(name, tool);
result.add(verification);
}
if let Some(platform_tools) = lock.platforms.get(platform) {
for (name, tool) in platform_tools {
if !lock.tools.contains_key(name) {
let verification = verify_tool(name, tool);
result.add(verification);
}
}
}
result
}
fn verify_tool(name: &str, locked: &LockedTool) -> ToolVerification {
if !has(name) {
return ToolVerification {
name: name.to_string(),
status: VerificationStatus::NotInstalled,
locked_version: locked.version.clone(),
installed_version: None,
};
}
let installed_version = super::generate::get_installed_version(name);
match &installed_version {
Some(installed) => {
if versions_match(&locked.version, installed) {
ToolVerification {
name: name.to_string(),
status: VerificationStatus::Match,
locked_version: locked.version.clone(),
installed_version: Some(installed.clone()),
}
} else {
ToolVerification {
name: name.to_string(),
status: VerificationStatus::VersionMismatch,
locked_version: locked.version.clone(),
installed_version: Some(installed.clone()),
}
}
}
None => ToolVerification {
name: name.to_string(),
status: VerificationStatus::Unknown,
locked_version: locked.version.clone(),
installed_version: None,
},
}
}
fn versions_match(locked: &str, installed: &str) -> bool {
let locked_normalized = normalize_version(locked);
let installed_normalized = normalize_version(installed);
if locked_normalized == installed_normalized {
return true;
}
if installed_normalized.starts_with(&locked_normalized) {
let remainder = &installed_normalized[locked_normalized.len()..];
if remainder.is_empty() || remainder.starts_with('.') {
return true;
}
}
false
}
fn normalize_version(version: &str) -> String {
let version = version.strip_prefix('v').unwrap_or(version);
version.trim().to_string()
}
#[allow(dead_code)] pub fn verify_and_report(
lock: &LockFile,
platform: &str,
verbose: bool,
) -> Result<VerificationResult, LockError> {
let result = verify_lock(lock, platform);
if verbose {
for tool in &result.tools {
match tool.status {
VerificationStatus::Match => {
println!(" {} {} [match]", tool.name, tool.locked_version);
}
VerificationStatus::VersionMismatch => {
println!(
" {} {} != {} [mismatch]",
tool.name,
tool.installed_version.as_deref().unwrap_or("?"),
tool.locked_version
);
}
VerificationStatus::NotInstalled => {
println!(" {} {} [not installed]", tool.name, tool.locked_version);
}
VerificationStatus::NotLocked => {
println!(" {} [not in lock file]", tool.name);
}
VerificationStatus::Unknown => {
println!(" {} {} [unknown]", tool.name, tool.locked_version);
}
}
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_versions_match_exact() {
assert!(versions_match("2.45.0", "2.45.0"));
assert!(versions_match("1.0", "1.0"));
}
#[test]
fn test_versions_match_v_prefix() {
assert!(versions_match("v2.45.0", "2.45.0"));
assert!(versions_match("2.45.0", "v2.45.0"));
}
#[test]
fn test_versions_match_prefix() {
assert!(versions_match("2.45", "2.45.0"));
assert!(versions_match("2.45", "2.45.1"));
}
#[test]
fn test_versions_mismatch() {
assert!(!versions_match("2.45.0", "2.46.0"));
assert!(!versions_match("2.45", "2.46.0"));
assert!(!versions_match("1.0", "2.0"));
}
#[test]
fn test_normalize_version() {
assert_eq!(normalize_version("v1.2.3"), "1.2.3");
assert_eq!(normalize_version(" 1.2.3 "), "1.2.3");
assert_eq!(normalize_version("v1.2.3-beta"), "1.2.3-beta");
}
#[test]
fn test_verification_result() {
let mut result = VerificationResult::new();
result.add(ToolVerification {
name: "git".to_string(),
status: VerificationStatus::Match,
locked_version: "2.45.0".to_string(),
installed_version: Some("2.45.0".to_string()),
});
result.add(ToolVerification {
name: "node".to_string(),
status: VerificationStatus::VersionMismatch,
locked_version: "20.10.0".to_string(),
installed_version: Some("18.0.0".to_string()),
});
assert_eq!(result.matched, 1);
assert_eq!(result.mismatched, 1);
assert!(!result.all_match);
}
#[test]
fn test_verification_status_display() {
assert_eq!(VerificationStatus::Match.to_string(), "match");
assert_eq!(
VerificationStatus::VersionMismatch.to_string(),
"version mismatch"
);
assert_eq!(
VerificationStatus::NotInstalled.to_string(),
"not installed"
);
}
}