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,
pub in_range: bool,
}
#[derive(Debug, Deserialize)]
struct RawEntry {
#[serde(default)]
current: String,
#[serde(default)]
latest: String,
#[serde(default)]
wanted: String,
}
#[derive(Debug)]
pub enum OutdatedError {
NoManifest,
Unsupported(PackageManager),
NotInstalled,
Failed(String),
}
impl std::fmt::Display for OutdatedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoManifest => f.write_str("no package.json here"),
Self::Unsupported(manager) => {
write!(f, "opi cannot read {manager}'s outdated output")
}
Self::NotInstalled => {
f.write_str("cargo outdated is not installed; cargo install cargo-outdated adds it")
}
Self::Failed(reason) => f.write_str(reason),
}
}
}
pub fn run(
manager: PackageManager,
root: &Path,
workspace: bool,
) -> Result<Vec<Update>, OutdatedError> {
if !matches!(manager, PackageManager::Npm | PackageManager::Pnpm) {
return Err(OutdatedError::Unsupported(manager));
}
let mut args = vec!["outdated"];
if workspace && matches!(manager, PackageManager::Pnpm) {
args.push("-r");
}
args.push("--json");
let output = Command::new(manager.program())
.args(&args)
.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(json_from(&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),
in_range: !entry.wanted.is_empty() && entry.wanted != entry.current,
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)
}
#[derive(Debug, Deserialize)]
struct CargoReport {
#[serde(default)]
dependencies: Vec<CargoEntry>,
}
#[derive(Debug, Deserialize)]
struct CargoEntry {
#[serde(default)]
name: String,
#[serde(default)]
project: String,
#[serde(default)]
latest: String,
}
pub fn cargo(root: &Path) -> Result<Vec<Update>, OutdatedError> {
if !crate::cargo::has_subcommand("outdated") {
return Err(OutdatedError::NotInstalled);
}
let output = Command::new("cargo")
.args([
"outdated",
"--workspace",
"--root-deps-only",
"--format",
"json",
])
.current_dir(root)
.output()
.map_err(|error| OutdatedError::Failed(format!("could not run cargo outdated: {error}")))?;
let text = String::from_utf8_lossy(&output.stdout);
if text.trim().is_empty() {
let reason = String::from_utf8_lossy(&output.stderr);
let reason = reason
.lines()
.find(|line| line.trim_start().starts_with("error"))
.or_else(|| reason.lines().next())
.unwrap_or("no output")
.trim()
.to_owned();
if reason.is_empty() || reason == "no output" {
return Ok(Vec::new());
}
return Err(OutdatedError::Failed(format!("cargo outdated: {reason}")));
}
Ok(read_cargo(&text))
}
fn json_from(text: &str) -> &str {
text.char_indices()
.find(|(_, character)| *character == '{')
.map_or(text, |(at, _)| &text[at..])
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Apply {
InRange,
Latest,
}
pub fn can_raise(manager: PackageManager) -> bool {
matches!(manager, PackageManager::Pnpm)
}
pub fn apply(
manager: PackageManager,
root: &Path,
workspace: bool,
mode: Apply,
names: &[String],
) -> Result<(), OutdatedError> {
let mut args = vec!["update".to_owned()];
if workspace && matches!(manager, PackageManager::Pnpm) {
args.push("-r".to_owned());
}
if mode == Apply::Latest {
args.push("--latest".to_owned());
args.extend_from_slice(names);
}
let status = Command::new(manager.program())
.args(&args)
.current_dir(root)
.status()
.map_err(|error| OutdatedError::Failed(format!("could not run {manager}: {error}")))?;
if status.success() {
Ok(())
} else {
Err(OutdatedError::Failed(format!(
"{manager} update exited with {status}"
)))
}
}
fn read_cargo(text: &str) -> Vec<Update> {
let mut updates: Vec<Update> = text
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<CargoReport>(line).ok())
.flat_map(|report| report.dependencies)
.filter(|entry| {
!entry.name.is_empty()
&& !entry.project.is_empty()
&& !entry.latest.is_empty()
&& entry.project != entry.latest
})
.map(|entry| Update {
jump: Jump::between(&entry.project, &entry.latest),
in_range: false,
name: entry.name,
current: entry.project,
latest: entry.latest,
})
.collect();
updates.sort_by(|a, b| {
a.name
.cmp(&b.name)
.then_with(|| a.current.cmp(&b.current))
.then_with(|| a.latest.cmp(&b.latest))
});
updates.dedup_by(|a, b| a.name == b.name && a.current == b.current && a.latest == b.latest);
updates.sort_by(|a, b| a.jump.cmp(&b.jump).then_with(|| a.name.cmp(&b.name)));
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)));
}
const CARGO_WORKSPACE: &str = concat!(
r#"{"crate_name":"a","dependencies":[{"name":"serde_json","project":"1.0.100","compat":"---","latest":"1.0.151","kind":"Normal","platform":null}]}"#,
"\n",
r#"{"crate_name":"b","dependencies":[{"name":"glob","project":"0.3.0","compat":"---","latest":"0.3.4","kind":"Normal","platform":null},{"name":"serde_json","project":"1.0.100","compat":"---","latest":"1.0.151","kind":"Normal","platform":null}]}"#,
"\n",
);
#[test]
fn a_warning_printed_ahead_of_the_json_does_not_break_the_read() {
let noisy = "\u{203c} WARN\u{203c} Request took 11084ms: https://registry.npmjs.org/x\n{\"ms\":{\"current\":\"2.1.2\",\"wanted\":\"2.1.3\",\"latest\":\"2.1.3\"}}";
let raw: BTreeMap<String, RawEntry> =
serde_json::from_str(json_from(noisy)).expect("parse past the warning");
assert_eq!(raw["ms"].latest, "2.1.3");
}
#[test]
fn clean_output_is_untouched() {
let json = r#"{"ms":{"current":"2.1.2","wanted":"2.1.3","latest":"2.1.3"}}"#;
assert_eq!(json_from(json), json);
}
#[test]
fn in_range_is_wanted_against_current_not_the_semver_jump() {
let json = r#"{
"ms": {"current":"2.1.2","wanted":"2.1.2","latest":"2.1.3"},
"picocolors": {"current":"1.0.0","wanted":"1.1.1","latest":"1.1.1"}
}"#;
let raw: BTreeMap<String, RawEntry> = serde_json::from_str(json).expect("parse");
let mut seen: Vec<(String, bool, Jump)> = raw
.into_iter()
.map(|(name, entry)| {
(
name,
!entry.wanted.is_empty() && entry.wanted != entry.current,
Jump::between(&entry.current, &entry.latest),
)
})
.collect();
seen.sort();
assert_eq!(seen[0].0, "ms");
assert!(!seen[0].1, "a patch that the exact pin forbids");
assert_eq!(seen[0].2, Jump::Patch, "safe, and still not applicable");
assert_eq!(seen[1].0, "picocolors");
assert!(seen[1].1, "inside the declared range");
}
#[test]
fn only_pnpm_is_asked_to_raise_ranges() {
assert!(can_raise(PackageManager::Pnpm));
assert!(!can_raise(PackageManager::Npm));
assert!(!can_raise(PackageManager::Yarn));
assert!(!can_raise(PackageManager::Bun));
}
#[test]
fn a_cargo_update_is_never_offered() {
let text = r#"{"crate_name":"a","dependencies":[{"name":"glob","project":"0.3.0","compat":"---","latest":"0.3.4","kind":"Normal","platform":null}]}"#;
assert!(!read_cargo(text)[0].in_range);
}
#[test]
fn a_workspace_report_is_read_line_by_line() {
let updates = read_cargo(CARGO_WORKSPACE);
assert_eq!(updates.len(), 2);
assert!(updates.iter().any(|u| u.name == "glob"));
assert!(updates.iter().any(|u| u.name == "serde_json"));
}
#[test]
fn a_crate_two_members_share_is_listed_once() {
let updates = read_cargo(CARGO_WORKSPACE);
let named: Vec<&str> = updates
.iter()
.filter(|u| u.name == "serde_json")
.map(|u| u.name.as_str())
.collect();
assert_eq!(named.len(), 1, "once, not once per member");
}
#[test]
fn a_cargo_entry_fills_the_same_update_as_npm() {
let updates = read_cargo(CARGO_WORKSPACE);
let glob = updates.iter().find(|u| u.name == "glob").unwrap();
assert_eq!(glob.current, "0.3.0");
assert_eq!(glob.latest, "0.3.4");
assert_eq!(glob.jump, Jump::Patch);
let json = updates.iter().find(|u| u.name == "serde_json").unwrap();
assert_eq!(json.jump, Jump::Patch);
}
#[test]
fn a_cargo_report_with_nothing_outdated_yields_nothing() {
assert!(read_cargo(r#"{"crate_name":"opi","dependencies":[]}"#).is_empty());
}
#[test]
fn an_unreadable_cargo_line_is_skipped_rather_than_fatal() {
let text = format!("not json\n{CARGO_WORKSPACE}");
assert_eq!(read_cargo(&text).len(), 2);
}
#[test]
fn a_cargo_zero_x_minor_is_breaking_here_too() {
let text = r#"{"crate_name":"llmux","dependencies":[{"name":"reqwest","project":"0.12.28","compat":"---","latest":"0.13.5","kind":"Normal","platform":null}]}"#;
assert_eq!(read_cargo(text)[0].jump, Jump::Major);
}
#[test]
fn only_major_counts_as_breaking() {
assert!(Jump::Major.breaking());
assert!(!Jump::Minor.breaking());
assert!(!Jump::Patch.breaking());
}
#[test]
fn a_manager_opi_cannot_read_is_refused_rather_than_run() {
for manager in [PackageManager::Bun, PackageManager::Yarn] {
let error = run(manager, Path::new("."), false).expect_err("unsupported");
assert!(
matches!(error, OutdatedError::Unsupported(_)),
"for {manager}"
);
}
}
#[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
);
}
}