Skip to content
This repository was archived by the owner on Mar 13, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ jobs:
matrix:
include:
- os: macos-latest
file-name: nvm-rust
file-name: nvm
- os: ubuntu-latest
file-name: nvm-rust
file-name: nvm
- os: windows-latest
file-name: nvm-rust.exe
file-name: nvm.exe

runs-on: ${{ matrix.os }}

Expand Down Expand Up @@ -53,7 +53,7 @@ jobs:
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: build-${{ matrix.os }}
name: nvm-${{ matrix.os }}
path: target/release/${{ matrix.file-name }}

test:
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
on:
workflow_dispatch:
workflow_dispatch:
push:
tags:
- v*
Expand Down Expand Up @@ -54,13 +54,13 @@ jobs:
matrix:
include:
- os: macos-latest
file-name: nvm-rust
file-name: nvm
display-name: nvm-macos
- os: ubuntu-latest
file-name: nvm-rust
file-name: nvm
display-name: nvm-linux
- os: windows-latest
file-name: nvm-rust.exe
file-name: nvm.exe
display-name: nvm-win.exe

runs-on: ${{ matrix.os }}
Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ exclude = [
"test-data/",
]

[[bin]]
name = "nvm"
path = "src/main.rs"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
Expand Down
46 changes: 32 additions & 14 deletions src/node_version.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::{
borrow::Borrow,
cmp::Ordering,
collections::HashMap,
fs::{read_link, remove_dir_all},
path::PathBuf,
Expand All @@ -16,6 +17,26 @@ pub trait NodeVersion {
fn version(&self) -> &Version;
}

impl PartialEq<Self> for dyn NodeVersion {
fn eq(&self, other: &Self) -> bool {
self.version().eq(other.version())
}
}

impl Eq for dyn NodeVersion {}

impl PartialOrd<Self> for dyn NodeVersion {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.version().cmp(other.version()))
}
}

impl Ord for dyn NodeVersion {
fn cmp(&self, other: &Self) -> Ordering {
self.version().cmp(other.version())
}
}

pub fn is_version_range(value: &str) -> Result<Range> {
Range::parse(value).context(value.to_string())
}
Expand All @@ -27,8 +48,8 @@ pub fn filter_version_req<V: NodeVersion>(versions: Vec<V>, version_range: &Rang
.collect()
}

pub fn get_latest_of_each_major<'p, V: NodeVersion>(versions: &'p [V]) -> HashMap<u64, &'p V> {
let mut map: HashMap<u64, &'p V> = HashMap::new();
pub fn get_latest_of_each_major<V: NodeVersion>(versions: &[V]) -> Vec<&V> {
let mut map: HashMap<u64, &V> = HashMap::new();

for version in versions.iter() {
let entry = map.get_mut(&version.version().major);
Expand All @@ -39,7 +60,7 @@ pub fn get_latest_of_each_major<'p, V: NodeVersion>(versions: &'p [V]) -> HashMa
map.insert(version.version().major, version);
}

map
map.values().cloned().collect()
}

/// Handles `vX.X.X` prefixes
Expand All @@ -54,7 +75,7 @@ fn parse_version_str(version_str: &str) -> Result<Version> {
Version::parse(clean_version).context(version_str.to_owned())
}

#[derive(Clone, Deserialize, Debug, Eq, PartialEq)]
#[derive(Clone, Deserialize, Debug, Eq, PartialEq, Ord, PartialOrd)]
#[serde(rename_all(deserialize = "snake_case"))]
pub struct OnlineNodeVersion {
#[serde()]
Expand All @@ -77,11 +98,7 @@ impl OnlineNodeVersion {
pub fn get_download_url(&self) -> Result<Url> {
let file_name = self.get_file();

let url = format!(
"https://nodejs.org/dist/v{}/{}",
self.version,
file_name
);
let url = format!("https://nodejs.org/dist/v{}/{}", self.version, file_name);

Url::parse(&url).context(format!("Could not create a valid download url. [{}]", url))
}
Expand Down Expand Up @@ -172,7 +189,7 @@ impl InstalledNodeVersion {
remove_dir_all(self.get_dir_path(config))?;

println!("Uninstalled {}!", self.version());
Result::Ok(())
Ok(())
}

/// Checks that all the required files are present in the installation dir
Expand All @@ -193,7 +210,7 @@ impl InstalledNodeVersion {
);
}

Result::Ok(())
Ok(())
}

// Static functions
Expand All @@ -218,7 +235,7 @@ impl InstalledNodeVersion {
let entry = entry.unwrap();
let result = parse_version_str(entry.file_name().to_string_lossy().as_ref());

if let Result::Ok(version) = result {
if let Ok(version) = result {
version_dirs.push(version);
}
}
Expand Down Expand Up @@ -264,10 +281,11 @@ impl NodeVersion for InstalledNodeVersion {
#[cfg(test)]
mod tests {
mod online_version {
use crate::node_version::OnlineNodeVersion;
use anyhow::Result;
use node_semver::Version;

use crate::node_version::OnlineNodeVersion;

#[test]
fn can_parse_version_data() -> Result<()> {
let expected = OnlineNodeVersion {
Expand Down Expand Up @@ -342,7 +360,7 @@ mod tests {

assert_eq!(expected, result);

Result::Ok(())
Ok(())
}
}
}
119 changes: 84 additions & 35 deletions src/subcommand/list.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
use std::{collections::HashMap, ops::Deref};

use anyhow::Result;
use clap::{AppSettings, Parser};
use itertools::Itertools;
use node_semver::Range;

use crate::{
Expand All @@ -11,24 +10,39 @@ use crate::{
Config,
};

enum VersionStatus {
Outdated(OnlineNodeVersion),
enum VersionStatus<'p> {
Latest,
Unknown,
NotInstalled,
Outdated(&'p OnlineNodeVersion),
}

fn emoji_from(status: &VersionStatus) -> char {
match status {
VersionStatus::Outdated(_) => '⏫',
_ => '✅',
impl<'p> VersionStatus<'p> {
fn from<T: NodeVersion>(versions: &[&T], latest: &'p OnlineNodeVersion) -> VersionStatus<'p> {
if versions.is_empty() {
VersionStatus::NotInstalled
} else if versions
.iter()
.all(|version| version.version() < latest.version())
{
VersionStatus::Outdated(latest)
} else {
VersionStatus::Latest
}
}
}

fn latest_version_string_from(status: &VersionStatus) -> String {
match status {
VersionStatus::Outdated(version) => format!("-> {}", version.to_string()),
VersionStatus::Latest => "".to_string(),
_ => "-> unknown".to_string(),
fn to_emoji(&self) -> char {
match self {
VersionStatus::Latest => '✅',
VersionStatus::NotInstalled => '〰',
VersionStatus::Outdated(_) => '⏫',
}
}

fn to_version_string(&self) -> String {
match self {
VersionStatus::Outdated(version) => format!("-> {}", version.to_string()),
_ => "".to_string(),
}
}
}

Expand All @@ -40,11 +54,8 @@ setting = AppSettings::ColoredHelp
)]
pub struct ListCommand {
/// Only display installed versions
#[clap(short, long)]
pub installed: bool,
/// Only display available versions
#[clap(short, long, takes_value(false))]
pub online: bool,
#[clap(short, long, alias = "installed")]
pub local: bool,
/// Filter by semantic versions.
///
/// `12`, `^10.9`, `>=8.10`, `>=8, <9`
Expand All @@ -61,33 +72,71 @@ impl Action<ListCommand> for ListCommand {
installed_versions = node_version::filter_version_req(installed_versions, filter);
}

let mut latest_per_major: HashMap<u64, &OnlineNodeVersion> = HashMap::new();
if options.local {
println!(
"{}",
installed_versions
.iter()
.map(|version| version.to_string())
.join("\n")
);

return Ok(());
}

// Get available versions, extract only the latest for each major version
let mut latest_per_major = Vec::<&OnlineNodeVersion>::new();
let online_versions = OnlineNodeVersion::fetch_all()?;
if !online_versions.is_empty() {
latest_per_major = node_version::get_latest_of_each_major(&online_versions);
latest_per_major.sort();
latest_per_major.reverse();
}

let lines: Vec<String> = installed_versions
let majors_and_installed_versions: Vec<(&OnlineNodeVersion, Vec<&InstalledNodeVersion>)> =
latest_per_major
.into_iter()
.map(|latest| {
(
latest,
installed_versions
.iter()
.filter(|installed| installed.version().major == latest.version().major)
.collect(),
)
})
.collect();

// Show the latest X major versions by default
// and show any older, installed versions as well
let mut versions_to_show = Vec::<(&OnlineNodeVersion, &Vec<&InstalledNodeVersion>)>::new();
for (i, (latest, installed)) in majors_and_installed_versions.iter().enumerate() {
if i < 5 || !installed.is_empty() {
versions_to_show.push((latest, installed));
}
}

let output = versions_to_show
.iter()
.map(|version| {
let version_status = match latest_per_major.get(&version.version().major) {
Some(latest) if latest.version().gt(version.version()) => {
VersionStatus::Outdated(latest.deref().clone())
},
Some(_) => VersionStatus::Latest,
None => VersionStatus::Unknown,
.map(|(online_version, installed_versions)| {
let version_status = VersionStatus::from(installed_versions, online_version);

let version_to_show = if installed_versions.is_empty() {
online_version.to_string()
} else {
installed_versions[0].to_string()
};

format!(
"{} {} {}",
emoji_from(&version_status),
version.to_string(),
latest_version_string_from(&version_status)
&version_status.to_emoji(),
version_to_show,
&version_status.to_version_string(),
)
})
.collect();
.join("\n");

println!("{}", lines.join("\n"));
Result::Ok(())
println!("{output}");
Ok(())
}
}
2 changes: 1 addition & 1 deletion tests/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ fn integration_dir() -> TempDir {
pub fn setup_integration_test() -> Result<(TempDir, Command)> {
let temp_dir = integration_dir();

let mut cmd = Command::cargo_bin("nvm-rust").expect("Could not create Command");
let mut cmd = Command::cargo_bin("nvm").expect("Could not create Command");
cmd.args(&["--dir", &temp_dir.to_string_lossy()]);

Result::Ok((temp_dir, cmd))
Expand Down