use super::config;
#[must_use]
pub fn compute_version_divergence_score(
version_a: &Option<String>,
version_b: &Option<String>,
weights: &config::MultiFieldWeights,
) -> f64 {
match (version_a, version_b) {
(Some(va), Some(vb)) if va == vb => 1.0,
(None, None) => 0.5, (Some(va), Some(vb)) => {
let parts_a = parse_semver_parts(va);
let parts_b = parse_semver_parts(vb);
if let (Some(ref pa), Some(ref pb)) = (parts_a, parts_b) {
let (maj_a, min_a, patch_a) = pa.triple();
let (maj_b, min_b, patch_b) = pb.triple();
let score = if maj_a == maj_b && min_a == min_b {
let patch_diff =
(i64::from(patch_a) - i64::from(patch_b)).unsigned_abs() as f64;
patch_diff.mul_add(-0.01, 0.8).max(0.5)
} else if maj_a == maj_b {
let minor_diff = (i64::from(min_a) - i64::from(min_b)).unsigned_abs() as f64;
minor_diff
.mul_add(-weights.version_minor_penalty, 0.5)
.max(0.2)
} else {
let major_diff = (i64::from(maj_a) - i64::from(maj_b)).unsigned_abs() as f64;
major_diff
.mul_add(-weights.version_major_penalty, 0.3)
.max(0.0)
};
let pre_release_penalty = match (&pa.pre_release, &pb.pre_release) {
(None, None) => 0.0,
(Some(a), Some(b)) if a == b => 0.0,
(Some(_), Some(_)) => 0.05,
(None, Some(_)) | (Some(_), None) => 0.15,
};
(score - pre_release_penalty).max(0.0)
} else {
let common_prefix_len = va
.chars()
.zip(vb.chars())
.take_while(|(a, b)| a == b)
.count();
let max_len = va.len().max(vb.len());
if max_len > 0 && common_prefix_len > 0 {
(common_prefix_len as f64 / max_len as f64 * 0.5).min(0.4)
} else {
0.1 }
}
}
_ => 0.0, }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SemverParts {
pub major: u32,
pub minor: u32,
pub patch: u32,
pub pre_release: Option<String>,
}
impl SemverParts {
#[must_use]
pub const fn is_release(&self) -> bool {
self.pre_release.is_none()
}
#[must_use]
pub const fn triple(&self) -> (u32, u32, u32) {
(self.major, self.minor, self.patch)
}
}
#[must_use]
pub fn parse_semver_parts(version: &str) -> Option<SemverParts> {
let version = version.trim_start_matches(['v', 'V']);
let (version_part, pre_release) = match version.split_once('-') {
Some((v, rest)) => {
let pre = rest.split('+').next().unwrap_or(rest);
(v, Some(pre.to_string()))
}
None => {
let v = version.split('+').next().unwrap_or(version);
(v, None)
}
};
let mut parts = version_part.split('.');
let major: u32 = parts.next()?.parse().ok()?;
let minor: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let patch: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
Some(SemverParts {
major,
minor,
patch,
pre_release,
})
}
#[derive(Debug, Clone, Default)]
pub struct MultiFieldScoreResult {
pub total: f64,
pub name_score: f64,
pub version_score: f64,
pub ecosystem_score: f64,
pub license_score: f64,
pub supplier_score: f64,
pub group_score: f64,
}
impl MultiFieldScoreResult {
#[must_use]
pub fn summary(&self) -> String {
format!(
"Total: {:.2} (name: {:.2}, version: {:.2}, ecosystem: {:.2}, licenses: {:.2}, supplier: {:.2}, group: {:.2})",
self.total,
self.name_score,
self.version_score,
self.ecosystem_score,
self.license_score,
self.supplier_score,
self.group_score
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_semver_basic() {
let p = parse_semver_parts("1.2.3").unwrap();
assert_eq!(p.triple(), (1, 2, 3));
assert!(p.is_release());
}
#[test]
fn test_parse_semver_with_prefix() {
let p = parse_semver_parts("v1.2.3").unwrap();
assert_eq!(p.triple(), (1, 2, 3));
}
#[test]
fn test_parse_semver_major_only() {
let p = parse_semver_parts("3").unwrap();
assert_eq!(p.triple(), (3, 0, 0));
}
#[test]
fn test_parse_semver_invalid() {
assert_eq!(parse_semver_parts("abc"), None);
}
#[test]
fn test_parse_semver_prerelease() {
let p = parse_semver_parts("1.2.3-alpha.1").unwrap();
assert_eq!(p.triple(), (1, 2, 3));
assert_eq!(p.pre_release.as_deref(), Some("alpha.1"));
assert!(!p.is_release());
}
#[test]
fn test_parse_semver_prerelease_rc() {
let p = parse_semver_parts("2.0.0-rc.1").unwrap();
assert_eq!(p.triple(), (2, 0, 0));
assert_eq!(p.pre_release.as_deref(), Some("rc.1"));
}
#[test]
fn test_parse_semver_build_metadata_stripped() {
let p = parse_semver_parts("1.2.3+build.456").unwrap();
assert_eq!(p.triple(), (1, 2, 3));
assert!(p.pre_release.is_none());
assert!(p.is_release());
}
#[test]
fn test_parse_semver_prerelease_with_build() {
let p = parse_semver_parts("1.0.0-beta.2+build.123").unwrap();
assert_eq!(p.triple(), (1, 0, 0));
assert_eq!(p.pre_release.as_deref(), Some("beta.2"));
}
#[test]
fn test_version_divergence_prerelease_penalty() {
let weights = config::MultiFieldWeights::default();
let release_pair = compute_version_divergence_score(
&Some("1.2.3".into()),
&Some("1.2.4".into()),
&weights,
);
let mixed_pair = compute_version_divergence_score(
&Some("1.2.3".into()),
&Some("1.2.3-alpha".into()),
&weights,
);
assert!(
mixed_pair < release_pair,
"Pre-release mismatch ({mixed_pair}) should score lower than patch diff ({release_pair})"
);
}
#[test]
fn test_version_divergence_same_prerelease() {
let weights = config::MultiFieldWeights::default();
let score = compute_version_divergence_score(
&Some("1.2.3-alpha.1".into()),
&Some("1.2.3-alpha.1".into()),
&weights,
);
assert_eq!(
score, 1.0,
"Identical pre-release versions should score 1.0"
);
}
#[test]
fn test_version_divergence_exact() {
let weights = config::MultiFieldWeights::default();
let v1 = Some("1.2.3".to_string());
let v2 = Some("1.2.3".to_string());
assert_eq!(compute_version_divergence_score(&v1, &v2, &weights), 1.0);
}
#[test]
fn test_version_divergence_same_major_minor() {
let weights = config::MultiFieldWeights::default();
let v1 = Some("1.2.3".to_string());
let v2 = Some("1.2.5".to_string());
let score = compute_version_divergence_score(&v1, &v2, &weights);
assert!((0.5..=0.8).contains(&score));
}
#[test]
fn test_version_divergence_none() {
let weights = config::MultiFieldWeights::default();
assert_eq!(
compute_version_divergence_score(&None, &None, &weights),
0.5
);
}
#[test]
fn test_multi_field_score_result_summary() {
let result = MultiFieldScoreResult {
total: 0.85,
name_score: 0.9,
version_score: 1.0,
ecosystem_score: 1.0,
license_score: 0.5,
supplier_score: 0.0,
group_score: 1.0,
};
let summary = result.summary();
assert!(summary.contains("0.85"));
}
}