use std::collections::BTreeMap;
use std::path::Path;
use std::process::Command;
use serde::Deserialize;
use crate::project::PackageManager;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Jump {
Patch,
Minor,
Major,
}
impl Jump {
pub fn label(self) -> &'static str {
match self {
Self::Patch => "patch",
Self::Minor => "minor",
Self::Major => "major",
}
}
pub fn breaking(self) -> bool {
self == Self::Major
}
fn between(current: &str, latest: &str) -> Self {
let (Some(from), Some(to)) = (parts(current), parts(latest)) else {
return Self::Major;
};
if from.0 != to.0 || (from.0 == 0 && from.1 != to.1) {
Self::Major
} else if from.1 != to.1 {
Self::Minor
} else {
Self::Patch
}
}
}
fn parts(version: &str) -> Option<(u64, u64, u64)> {
let core = version
.trim_start_matches(['^', '~', 'v', '='])
.split(['-', '+'])
.next()?;
let mut numbers = core.split('.').map(str::parse::<u64>);
Some((
numbers.next()?.ok()?,
numbers.next().transpose().ok()?.unwrap_or(0),
numbers.next().transpose().ok()?.unwrap_or(0),
))
}
#[derive(Debug, Clone)]
pub struct Update {
pub name: String,
pub current: String,
pub latest: String,
pub jump: Jump,
}
#[derive(Debug, Deserialize)]
struct RawEntry {
#[serde(default)]
current: String,
#[serde(default)]
latest: String,
}
#[derive(Debug)]
pub enum OutdatedError {
Failed(String),
}
impl std::fmt::Display for OutdatedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Failed(reason) => f.write_str(reason),
}
}
}
pub fn run(manager: PackageManager, root: &Path) -> Result<Vec<Update>, OutdatedError> {
let output = Command::new(manager.program())
.args(["outdated", "--json"])
.current_dir(root)
.output()
.map_err(|error| OutdatedError::Failed(format!("could not run {manager}: {error}")))?;
let text = String::from_utf8_lossy(&output.stdout);
if text.trim().is_empty() {
return Ok(Vec::new());
}
let raw: BTreeMap<String, RawEntry> = serde_json::from_str(&text).map_err(|error| {
OutdatedError::Failed(format!("could not read {manager}'s output: {error}"))
})?;
let mut updates: Vec<Update> = raw
.into_iter()
.filter(|(_, entry)| !entry.current.is_empty() && entry.current != entry.latest)
.map(|(name, entry)| Update {
jump: Jump::between(&entry.current, &entry.latest),
name,
current: entry.current,
latest: entry.latest,
})
.collect();
updates.sort_by(|a, b| a.jump.cmp(&b.jump).then_with(|| a.name.cmp(&b.name)));
Ok(updates)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_patch_is_told_from_a_minor_and_a_major() {
assert_eq!(Jump::between("1.2.3", "1.2.4"), Jump::Patch);
assert_eq!(Jump::between("1.2.3", "1.3.0"), Jump::Minor);
assert_eq!(Jump::between("1.2.3", "2.0.0"), Jump::Major);
}
#[test]
fn a_pre_one_minor_counts_as_breaking() {
assert_eq!(Jump::between("0.44.2", "0.46.0"), Jump::Major);
assert_eq!(Jump::between("0.44.2", "0.44.3"), Jump::Patch);
}
#[test]
fn an_unparseable_version_is_treated_as_breaking() {
assert_eq!(Jump::between("next", "1.0.0"), Jump::Major);
assert_eq!(Jump::between("1.0.0", "canary"), Jump::Major);
}
#[test]
fn prefixes_and_pre_releases_do_not_confuse_the_comparison() {
assert_eq!(Jump::between("^1.2.3", "~1.2.9"), Jump::Patch);
assert_eq!(Jump::between("1.2.3", "1.2.4-beta.1"), Jump::Patch);
assert_eq!(parts("v2.1"), Some((2, 1, 0)));
}
#[test]
fn only_major_counts_as_breaking() {
assert!(Jump::Major.breaking());
assert!(!Jump::Minor.breaking());
assert!(!Jump::Patch.breaking());
}
#[test]
fn the_real_pnpm_shape_is_read() {
let json = r#"{"@biomejs/biome":{"current":"2.5.13","latest":"2.5.14","wanted":"2.5.13",
"dependencyType":"devDependencies"},
"fallow":{"current":"3.24.1","latest":"3.27.0","wanted":"3.24.1"}}"#;
let raw: BTreeMap<String, RawEntry> = serde_json::from_str(json).expect("parse");
assert_eq!(raw.len(), 2);
assert_eq!(
Jump::between(
&raw["@biomejs/biome"].current,
&raw["@biomejs/biome"].latest
),
Jump::Patch
);
assert_eq!(
Jump::between(&raw["fallow"].current, &raw["fallow"].latest),
Jump::Minor
);
}
}