use super::identity::{ActionId, Version, VersionPrecision};
use super::specifier::Specifier;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
InRange { candidate: Version },
CrossRange {
candidate: Version,
new_specifier: Specifier,
},
}
#[derive(Debug)]
pub struct Candidate {
pub id: ActionId,
pub current: Specifier,
pub action: Action,
}
impl Candidate {
#[must_use]
pub fn candidate(&self) -> &Version {
match &self.action {
Action::InRange { candidate } | Action::CrossRange { candidate, .. } => candidate,
}
}
#[must_use]
pub fn manifest_specifier(&self) -> &Specifier {
match &self.action {
Action::InRange { .. } => &self.current,
Action::CrossRange { new_specifier, .. } => new_specifier,
}
}
}
impl fmt::Display for Candidate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {} -> {}", self.id, self.current, self.candidate())
}
}
fn extract_at_precision(
candidate: &Version,
precision: VersionPrecision,
operator: char,
) -> Specifier {
let stripped = candidate
.0
.strip_prefix('v')
.or_else(|| candidate.0.strip_prefix('V'))
.unwrap_or(&candidate.0);
let base = stripped.split('-').next().unwrap_or(stripped);
let parts: Vec<&str> = base.split('.').collect();
let version_part = match (precision, parts.as_slice()) {
(VersionPrecision::Patch, [major, minor, patch, ..]) => {
format!("{major}.{minor}.{patch}")
}
(VersionPrecision::Minor, [major, minor, ..])
| (VersionPrecision::Patch, [major, minor]) => format!("{major}.{minor}"),
(VersionPrecision::Major, [major, ..]) | (_, [major]) => (*major).to_owned(),
(_, _) => stripped.to_owned(),
};
let raw = format!("{operator}{version_part}");
Specifier::parse(&raw)
}
#[must_use]
pub fn find_upgrade_candidate(
specifier: &Specifier,
lock_version: Option<&Version>,
candidates: &[Version],
allow_major: bool,
) -> Option<Action> {
let precision = specifier.precision()?;
let specifier_semver = parse_semver(specifier.as_str())?;
let manifest_is_prerelease = !specifier_semver.pre.is_empty();
let floor = if let Some(lock_ver) = lock_version {
if let Some(lock_semver) = parse_semver(lock_ver.as_str()) {
specifier_semver.clone().max(lock_semver)
} else {
specifier_semver.clone()
}
} else {
specifier_semver.clone()
};
let best_tag = candidates
.iter()
.filter_map(|c| {
let parsed = parse_semver(c.as_str())?;
if parsed <= floor {
return None;
}
if !manifest_is_prerelease && !parsed.pre.is_empty() {
return None;
}
if allow_major {
Some((c.clone(), parsed))
} else {
match precision {
VersionPrecision::Major | VersionPrecision::Minor => {
(parsed.major == specifier_semver.major).then_some((c.clone(), parsed))
}
VersionPrecision::Patch => {
(parsed.major == specifier_semver.major
&& parsed.minor == specifier_semver.minor)
.then_some((c.clone(), parsed))
}
}
}
})
.max_by(|(_, a), (_, b)| {
match (a.pre.is_empty(), b.pre.is_empty()) {
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
_ => a.cmp(b),
}
})
.map(|(tag, _)| tag)?;
if let Some(best_semver) = parse_semver(best_tag.as_str()) {
let is_in_range = specifier.matches(&best_semver);
if is_in_range {
Some(Action::InRange {
candidate: best_tag,
})
} else {
let operator = specifier.operator().unwrap_or('^');
let new_specifier = extract_at_precision(&best_tag, precision, operator);
Some(Action::CrossRange {
candidate: best_tag,
new_specifier,
})
}
} else {
None
}
}
fn parse_semver(version: &str) -> Option<semver::Version> {
let normalized = version
.trim_start_matches('^')
.trim_start_matches('~')
.strip_prefix('v')
.or_else(|| {
version
.trim_start_matches('^')
.trim_start_matches('~')
.strip_prefix('V')
})
.unwrap_or_else(|| version.trim_start_matches('^').trim_start_matches('~'));
if let Ok(v) = semver::Version::parse(normalized) {
return Some(v);
}
let with_patch = format!("{normalized}.0");
if let Ok(v) = semver::Version::parse(&with_patch) {
return Some(v);
}
let with_minor_patch = format!("{normalized}.0.0");
if let Ok(v) = semver::Version::parse(&with_minor_patch) {
return Some(v);
}
None
}
#[cfg(test)]
mod tests {
use super::{Action, ActionId, Candidate, Specifier, Version, find_upgrade_candidate};
#[test]
fn find_upgrade_candidate_safe_mode_major_precision_in_range() {
let specifier = Specifier::parse("^4");
let candidates = vec![
Version::from("v3"),
Version::from("v4"),
Version::from("v4.2.1"),
Version::from("v5"),
Version::from("v6"),
];
assert_eq!(
find_upgrade_candidate(&specifier, None, &candidates, false),
Some(Action::InRange {
candidate: Version::from("v4.2.1")
})
);
}
#[test]
fn find_upgrade_candidate_latest_mode_crosses_major() {
let specifier = Specifier::parse("^4");
let candidates = vec![
Version::from("v4"),
Version::from("v4.2.1"),
Version::from("v5.0.0"),
Version::from("v6.1.0"),
];
assert_eq!(
find_upgrade_candidate(&specifier, None, &candidates, true),
Some(Action::CrossRange {
candidate: Version::from("v6.1.0"),
new_specifier: Specifier::parse("^6"),
})
);
}
#[test]
fn find_upgrade_candidate_latest_mode_preserves_minor_precision() {
let specifier = Specifier::parse("^4.1");
let candidates = vec![Version::from("v5.0.0")];
assert_eq!(
find_upgrade_candidate(&specifier, None, &candidates, true),
Some(Action::CrossRange {
candidate: Version::from("v5.0.0"),
new_specifier: Specifier::parse("^5.0"),
})
);
}
#[test]
fn find_upgrade_candidate_latest_mode_preserves_patch_precision() {
let specifier = Specifier::parse("~4.1.2");
let candidates = vec![Version::from("v5.0.0")];
assert_eq!(
find_upgrade_candidate(&specifier, None, &candidates, true),
Some(Action::CrossRange {
candidate: Version::from("v5.0.0"),
new_specifier: Specifier::parse("~5.0.0"),
})
);
}
#[test]
fn find_upgrade_candidate_with_lock_floor() {
let specifier = Specifier::parse("^4");
let lock_version = Some(Version::from("v4.2.1"));
let candidates = vec![
Version::from("v4.2.1"),
Version::from("v4.3.0"),
Version::from("v5.0.0"),
];
assert_eq!(
find_upgrade_candidate(&specifier, lock_version.as_ref(), &candidates, false),
Some(Action::InRange {
candidate: Version::from("v4.3.0")
})
);
}
#[test]
fn find_upgrade_candidate_stable_filters_prerelease() {
let specifier = Specifier::parse("^2");
let candidates = vec![
Version::from("v2.2.1"),
Version::from("v3.0.0"),
Version::from("v3.0.0-beta.2"),
];
assert_eq!(
find_upgrade_candidate(&specifier, None, &candidates, true),
Some(Action::CrossRange {
candidate: Version::from("v3.0.0"),
new_specifier: Specifier::parse("^3"),
})
);
}
#[test]
fn find_upgrade_candidate_non_semver_specifier() {
let specifier = Specifier::Ref("main".to_owned());
let candidates = vec![Version::from("v5")];
assert!(find_upgrade_candidate(&specifier, None, &candidates, true).is_none());
}
#[test]
fn find_upgrade_candidate_no_candidates() {
let specifier = Specifier::parse("^4");
let candidates: Vec<Version> = vec![];
assert!(find_upgrade_candidate(&specifier, None, &candidates, true).is_none());
}
#[test]
fn upgrade_candidate_display_in_range() {
let candidate = Candidate {
id: ActionId::from("actions/checkout"),
current: Specifier::parse("^4"),
action: Action::InRange {
candidate: Version::from("v4.5.0"),
},
};
assert_eq!(candidate.to_string(), "actions/checkout ^4 -> v4.5.0");
}
#[test]
fn upgrade_candidate_display_cross_range() {
let candidate = Candidate {
id: ActionId::from("actions/checkout"),
current: Specifier::parse("^4"),
action: Action::CrossRange {
candidate: Version::from("v5.0.0"),
new_specifier: Specifier::parse("^5"),
},
};
assert_eq!(candidate.to_string(), "actions/checkout ^4 -> v5.0.0");
}
}