mod api;
mod index;
mod json;
mod lock;
mod manifest;
mod privileged;
mod report;
mod shadow;
mod stage;
#[cfg(feature = "tui")]
mod tui;
mod validate;
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use lock::{Mode, StateLock};
use manifest::{Entry, Manifest};
use report::{Checked, Report, Status};
use semver::Version;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use validate::{InstallSpec, validate_name};
const EXIT_UPDATES: u8 = 0;
const EXIT_ERROR: u8 = 1;
const EXIT_NO_UPDATES: u8 = 2;
#[derive(Parser)]
#[command(
name = "cargo-lbin",
version,
about = "Install crates.io binaries into <prefix>/bin (default /usr/local/bin)",
long_about = "Builds crates as the invoking user in a stage directory, then installs \
the resulting binaries into <prefix>/bin, escalating via sudo only for file \
placement. State lives in <prefix>/share/cargo-lbin/manifest.json. Sources are \
crates.io exclusively."
)]
struct Cli {
#[arg(
long,
global = true,
env = "CARGO_LBIN_PREFIX",
default_value = "/usr/local"
)]
prefix: PathBuf,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Install {
#[arg(required = true, value_name = "NAME[@VERSION]")]
crates: Vec<String>,
#[arg(long)]
locked: bool,
},
Remove {
#[arg(required = true)]
crates: Vec<String>,
},
Pin {
#[arg(required = true)]
crates: Vec<String>,
},
Unpin {
#[arg(required = true)]
crates: Vec<String>,
},
List {
#[arg(long)]
json: bool,
},
#[cfg(feature = "tui")]
Tui,
Info {
#[arg(required = true)]
crates: Vec<String>,
},
Search {
#[arg(required = true)]
query: Vec<String>,
#[arg(long, default_value_t = 10, value_parser = clap::value_parser!(u8).range(1..=100))]
limit: u8,
},
Checkupdate {
#[arg(long)]
json: bool,
},
Downgrade {
#[arg(value_name = "NAME")]
name: String,
},
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
Update {
#[arg(required_unless_present = "all", conflicts_with = "all")]
crates: Vec<String>,
#[arg(long)]
all: bool,
#[arg(long, short)]
yes: bool,
},
}
fn main() -> ExitCode {
let args = std::env::args_os()
.enumerate()
.filter_map(|(i, a)| (!(i == 1 && a == *"lbin")).then_some(a));
let cli = Cli::parse_from(args);
if unsafe { libc::geteuid() } == 0
&& std::env::var_os("CARGO_LBIN_ALLOW_ROOT").is_none_or(|v| v != "1")
{
eprintln!("error: cargo-lbin must not be run as root");
eprintln!("run it as your normal user; sudo is requested only when required for placement");
eprintln!("(set CARGO_LBIN_ALLOW_ROOT=1 only in environments where root is the only user)");
return ExitCode::from(EXIT_ERROR);
}
let result = match cli.cmd {
Cmd::Install { ref crates, locked } => cmd_install(&cli.prefix, crates, locked),
Cmd::Remove { ref crates } => cmd_remove(&cli.prefix, crates),
Cmd::Pin { ref crates } => cmd_set_pinned(&cli.prefix, crates, true),
Cmd::Unpin { ref crates } => cmd_set_pinned(&cli.prefix, crates, false),
Cmd::List { json } => cmd_list(&cli.prefix, json),
#[cfg(feature = "tui")]
Cmd::Tui => tui::run(&cli.prefix),
Cmd::Info { ref crates } => cmd_info(&cli.prefix, crates),
Cmd::Search { ref query, limit } => cmd_search(&cli.prefix, query, limit),
Cmd::Checkupdate { json } => return cmd_checkupdate(&cli.prefix, json),
Cmd::Completions { shell } => {
cmd_completions(shell);
Ok(())
}
Cmd::Downgrade { ref name } => cmd_downgrade(&cli.prefix, name),
Cmd::Update {
ref crates,
all,
yes,
} => cmd_update(&cli.prefix, crates, all, yes),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
ExitCode::from(EXIT_ERROR)
}
}
}
fn cache_dir() -> Result<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
return Ok(PathBuf::from(xdg).join("cargo-lbin"));
}
let home = std::env::var_os("HOME").context("neither XDG_CACHE_HOME nor HOME is set")?;
Ok(PathBuf::from(home).join(".cache/cargo-lbin"))
}
fn obsolete_bins(old: &[String], new: &[String]) -> Vec<String> {
old.iter().filter(|b| !new.contains(b)).cloned().collect()
}
fn newly_introduced_bins(old: &[String], new: &[String]) -> Vec<String> {
obsolete_bins(new, old)
}
struct RollbackSet {
new_names: Vec<String>,
placed: Vec<PathBuf>,
}
impl RollbackSet {
fn snapshot(manifest: &Manifest, name: &str, new_bins: &[String]) -> Self {
let old_bins = manifest
.crates
.get(name)
.map(|e| e.bins.clone())
.unwrap_or_default();
Self {
new_names: newly_introduced_bins(&old_bins, new_bins),
placed: Vec::new(),
}
}
fn note_placed(&mut self, bin: &str, dest: PathBuf) {
if self.new_names.iter().any(|n| n == bin) {
self.placed.push(dest);
}
}
}
fn rollback_new_bins(policy: privileged::Escalation, placed: &[PathBuf]) {
if placed.is_empty() {
return;
}
eprintln!("rolling back newly installed binaries");
for path in placed {
if privileged::remove_files(policy, &[path.as_path()]).is_err() {
eprintln!(
"warning: could not remove {}; remove it manually before retrying",
path.display()
);
}
}
}
fn check_collisions(
manifest: &Manifest,
name: &str,
bins: &[String],
bin_dir: &Path,
) -> Result<()> {
for bin in bins {
let owned_by_self = manifest
.crates
.get(name)
.is_some_and(|e| e.bins.contains(bin));
if owned_by_self {
continue;
}
if let Some((other, _)) = manifest
.crates
.iter()
.find(|(n, e)| n.as_str() != name && e.bins.contains(bin))
{
bail!("binary `{bin}` is already provided by crate `{other}`");
}
let dest = bin_dir.join(bin);
if dest.symlink_metadata().is_ok() {
bail!(
"{} already exists and is not managed by cargo-lbin \
(if it is a leftover from an interrupted run, remove it and retry)",
dest.display()
);
}
}
Ok(())
}
fn install_and_commit(
prefix: &Path,
cache: &Path,
manifest: &mut Manifest,
name: &str,
version: Option<&Version>,
locked: bool,
) -> Result<()> {
validate_name(name)?;
let policy = privileged::Escalation::for_prefix(prefix);
let _ = policy.probe_destination(&prefix.join("bin"))?;
let stage_dir = cache
.join("stage")
.join(std::process::id().to_string())
.join(name);
if stage_dir.exists() {
fs::remove_dir_all(&stage_dir)
.with_context(|| format!("clearing stale stage {}", stage_dir.display()))?;
}
let built = stage::build(name, version, locked, &stage_dir)?;
check_collisions(manifest, name, &built.bins, &prefix.join("bin"))?;
let new_bins: Vec<String> = built
.bins
.iter()
.filter(|b| {
!manifest
.crates
.get(name)
.is_some_and(|e| e.bins.contains(b))
})
.cloned()
.collect();
warn_shadows(prefix, &new_bins);
let mut rollback = RollbackSet::snapshot(manifest, name, &built.bins);
let pin = version.is_some();
if let Err(err) = place_and_commit(
prefix,
policy,
manifest,
name,
built,
locked,
pin,
&mut rollback,
) {
rollback_new_bins(policy, &rollback.placed);
return Err(err);
}
let _ = fs::remove_dir_all(&stage_dir);
if let Some(pid_dir) = stage_dir.parent() {
let _ = fs::remove_dir(pid_dir);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn place_and_commit(
prefix: &Path,
policy: privileged::Escalation,
manifest: &mut Manifest,
name: &str,
built: stage::Built,
locked: bool,
pin: bool,
rollback: &mut RollbackSet,
) -> Result<()> {
let bin_dir = prefix.join("bin");
let verified: Vec<privileged::VerifiedSource> = built
.bin_paths
.iter()
.map(|p| privileged::VerifiedSource::open(p))
.collect::<Result<_>>()?;
for (src, bin) in verified.iter().zip(&built.bins) {
let dest = bin_dir.join(bin);
privileged::install_verified(policy, src, &dest, "755")?;
rollback.note_placed(bin, dest);
}
drop(verified);
let installed: Vec<PathBuf> = built.bins.iter().map(|b| bin_dir.join(b)).collect();
let installed_refs: Vec<&Path> = installed.iter().map(PathBuf::as_path).collect();
privileged::restorecon(policy, &installed_refs);
if let Some(old) = manifest.crates.get(name) {
let obsolete = obsolete_bins(&old.bins, &built.bins);
if !obsolete.is_empty() {
let paths: Vec<PathBuf> = obsolete.iter().map(|b| bin_dir.join(b)).collect();
let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
privileged::remove_files(policy, &refs)?;
println!("removed obsolete binaries: {}", obsolete.join(", "));
}
}
let bins_list = built.bins.join(", ");
let pinned = pin || manifest.crates.get(name).is_some_and(|e| e.pinned);
commit_entry(
manifest,
prefix,
name,
Entry {
version: built.version.to_string(),
bins: built.bins,
locked,
pinned,
},
)?;
let pin_note = if pin {
format!(" [pinned; `cargo lbin unpin {name}` to allow updates]")
} else {
String::new()
};
println!(
"installed {name} {} -> {} ({bins_list}){pin_note}",
built.version,
bin_dir.display(),
);
Ok(())
}
fn warn_shadows(prefix: &Path, bins: &[String]) {
if bins.is_empty() {
return;
}
let Some(path_var) = std::env::var_os("PATH") else {
return;
};
let Ok(cwd) = std::env::current_dir() else {
return;
};
let prefix_bin = prefix.join("bin");
for s in shadow::find_shadows(&path_var, &prefix_bin, bins, &cwd, shadow::is_executable) {
let owner = shadow::owner_of(&s.existing);
eprintln!(
"warning: {}",
shadow::describe(&s, &prefix_bin, owner.as_deref())
);
}
}
fn commit_entry(manifest: &mut Manifest, prefix: &Path, name: &str, entry: Entry) -> Result<()> {
let previous = manifest.crates.insert(name.to_owned(), entry);
if let Err(err) = manifest.store(prefix) {
if let Some(old) = previous {
manifest.crates.insert(name.to_owned(), old);
} else {
manifest.crates.remove(name);
}
return Err(err);
}
Ok(())
}
fn cmd_install(prefix: &Path, crates: &[String], locked: bool) -> Result<()> {
let specs = InstallSpec::parse_all(crates)?;
let cache = cache_dir()?;
let _lock = StateLock::acquire(prefix, &Mode::Exclusive)?;
let mut manifest = Manifest::load(prefix)?;
let unversioned: Vec<String> = specs
.iter()
.filter(|s| s.version.is_none())
.map(|s| s.name.clone())
.collect();
refuse_pinned(&manifest, &unversioned)?;
for spec in &specs {
install_and_commit(
prefix,
&cache,
&mut manifest,
&spec.name,
spec.version.as_ref(),
locked,
)?;
}
Ok(())
}
fn refuse_pinned(manifest: &Manifest, crates: &[String]) -> Result<()> {
let pinned: Vec<&str> = crates
.iter()
.filter(|n| manifest.crates.get(n.as_str()).is_some_and(|e| e.pinned))
.map(String::as_str)
.collect();
if !pinned.is_empty() {
let names = pinned.join(" ");
bail!(
"pinned: {} (run `cargo lbin unpin {names}` first)",
pinned.join(", ")
);
}
Ok(())
}
fn cmd_set_pinned(prefix: &Path, crates: &[String], pinned: bool) -> Result<()> {
for name in crates {
validate_name(name)?;
}
let _lock = StateLock::acquire(prefix, &Mode::Exclusive)?;
let mut manifest = Manifest::load(prefix)?;
let targets = select_targets(&manifest, crates)?;
let verb = if pinned { "pinned" } else { "unpinned" };
let mut changed: Vec<String> = Vec::new();
for name in &targets {
if let Some(entry) = manifest.crates.get_mut(name) {
if entry.pinned == pinned {
println!("{name} is already {verb}");
} else {
entry.pinned = pinned;
changed.push(format!("{verb} {name} at {}", entry.version));
}
}
}
if !changed.is_empty() {
manifest.store(prefix)?;
for line in changed {
println!("{line}");
}
}
Ok(())
}
fn cmd_remove(prefix: &Path, crates: &[String]) -> Result<()> {
let _lock = StateLock::acquire(prefix, &Mode::Exclusive)?;
let policy = privileged::Escalation::for_prefix(prefix);
let mut manifest = Manifest::load(prefix)?;
let bin_dir = prefix.join("bin");
let mut removed_any = false;
for name in crates {
let Some(entry) = manifest.crates.remove(name) else {
eprintln!("warning: `{name}` is not in the manifest, skipping");
continue;
};
let paths: Vec<PathBuf> = entry.bins.iter().map(|b| bin_dir.join(b)).collect();
let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
privileged::remove_files(policy, &refs)?;
manifest.store(prefix)?;
println!("removed {name} ({})", entry.bins.join(", "));
removed_any = true;
}
if !removed_any {
bail!("nothing to remove");
}
Ok(())
}
fn cmd_list(prefix: &Path, json: bool) -> Result<()> {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
let manifest = Manifest::load(prefix)?;
let report = match cache_dir().and_then(|cache| Report::load(&cache, prefix)) {
Ok(report) => report,
Err(e) => {
eprintln!("warning: {e:#}");
None
}
};
if json {
let output = json::ListOutput::build(report::identity(prefix)?, &manifest, report.as_ref());
return json::print(&output);
}
if manifest.crates.is_empty() {
println!("no crates installed under {}", prefix.display());
return Ok(());
}
for (name, entry) in &manifest.crates {
let locked = if entry.locked { " [locked]" } else { "" };
let pinned = if entry.pinned { " [pinned]" } else { "" };
let status = Version::parse(&entry.version)
.ok()
.and_then(|current| report.as_ref()?.status_for(name, ¤t))
.map(|status| match status {
Status::Outdated(latest) => format!(" -> {latest}"),
Status::UpToDate => " (up to date)".to_owned(),
})
.unwrap_or_default();
println!(
"{name} {}{locked}{pinned} ({}){status}",
entry.version,
entry.bins.join(", ")
);
}
if let Some(r) = report {
eprintln!("update check: {}", report::describe_age(r.age()));
} else {
eprintln!("no update check recorded; run `cargo lbin checkupdate`");
}
Ok(())
}
fn select_targets(manifest: &Manifest, crates: &[String]) -> Result<BTreeSet<String>> {
let unknown: Vec<&str> = crates
.iter()
.filter(|n| !manifest.crates.contains_key(n.as_str()))
.map(String::as_str)
.collect();
if !unknown.is_empty() {
bail!("not installed: {}", unknown.join(", "));
}
Ok(crates.iter().cloned().collect())
}
fn check_versions<'a>(
entries: impl IntoIterator<Item = (&'a String, &'a Entry)>,
) -> Result<Vec<Checked>> {
let mut checked = Vec::new();
for (name, entry) in entries {
let current = Version::parse(&entry.version)
.with_context(|| format!("manifest holds unparsable version for `{name}`"))?;
let versions = index::published_versions(name)?;
let latest = index::latest_relevant(&versions, ¤t)
.filter(|latest| *latest > current)
.unwrap_or_else(|| current.clone());
checked.push(Checked {
name: name.clone(),
current,
latest,
});
}
Ok(checked)
}
fn release_label(release: &index::Release) -> String {
if release.yanked {
format!("{} [yanked]", release.version)
} else {
release.version.to_string()
}
}
fn describe_info(name: &str, releases: &[index::Release], installed: Option<&Entry>) -> String {
use std::fmt::Write as _;
let summary = index::summarize(releases);
let mut out = format!("{name}\n");
if let Some(stable) = &summary.latest_stable {
let _ = writeln!(out, " latest: {}", release_label(stable));
} else {
out.push_str(" latest: (no stable release)\n");
}
if let Some(pre) = &summary.latest_pre {
let _ = writeln!(out, " pre-release: {}", release_label(pre));
}
let _ = write!(out, " releases: {}", summary.total);
if summary.yanked > 0 {
let _ = write!(out, " ({} yanked)", summary.yanked);
}
out.push('\n');
let Some(entry) = installed else {
out.push_str(" installed: no\n");
return out;
};
let _ = write!(out, " installed: {}", entry.version);
let live: Vec<Version> = releases
.iter()
.filter(|r| !r.yanked)
.map(|r| r.version.clone())
.collect();
if live.is_empty() {
out.push_str(" (no non-yanked releases)\n");
return out;
}
let newer = Version::parse(&entry.version).ok().and_then(|current| {
index::latest_relevant(&live, ¤t).filter(|latest| *latest > current)
});
if let Some(latest) = newer {
let _ = writeln!(out, " (update available: {latest})");
} else {
out.push_str(" (up to date)\n");
}
out
}
fn cmd_info(prefix: &Path, crates: &[String]) -> Result<()> {
for name in crates {
validate_name(name)?;
}
let manifest = {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
Manifest::load(prefix)?
};
let mut names: Vec<&str> = Vec::new();
for name in crates {
if !names.contains(&name.as_str()) {
names.push(name);
}
}
let mut failures: Vec<anyhow::Error> = Vec::new();
let mut shown = 0usize;
for name in &names {
match index::releases(name) {
Ok(Some(releases)) => {
if shown > 0 {
println!();
}
print!(
"{}",
describe_info(name, &releases, manifest.crates.get(*name))
);
shown += 1;
}
Ok(None) => failures.push(anyhow::anyhow!(
"{}; try `cargo lbin search {name}`",
index::not_found(name)
)),
Err(e) => failures.push(e),
}
}
if failures.is_empty() {
return Ok(());
}
if names.len() == 1 {
return Err(failures.remove(0));
}
for e in &failures {
eprintln!("error: {e:#}");
}
bail!("{} of {} lookups failed", failures.len(), names.len())
}
const SEARCH_DESCRIPTION_WIDTH: usize = 72;
fn format_search_hits(hits: &[api::Hit], installed: &BTreeMap<String, String>) -> String {
use std::fmt::Write as _;
let name_w = hits.iter().map(|h| h.name.len()).max().unwrap_or(0);
let version_w = hits.iter().map(|h| h.version.len()).max().unwrap_or(0);
let mut out = String::new();
for hit in hits {
let mark = if installed.contains_key(&hit.name) {
'*'
} else {
' '
};
let mut description: String = hit
.description
.chars()
.take(SEARCH_DESCRIPTION_WIDTH)
.collect();
if hit.description.chars().count() > SEARCH_DESCRIPTION_WIDTH {
description.push('…');
}
let _ = write!(
out,
"{mark} {:<name_w$} {:<version_w$} {description}",
hit.name, hit.version
);
if let Some(have) = installed.get(&hit.name) {
let _ = write!(out, " [installed {have}]");
}
out.push('\n');
}
out
}
fn cmd_search(prefix: &Path, query: &[String], limit: u8) -> Result<()> {
let query = query.join(" ");
let hits = api::search(&query, usize::from(limit))?;
if hits.is_empty() {
println!("no crates match `{query}`");
return Ok(());
}
let installed: BTreeMap<String, String> = {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
Manifest::load(prefix)?
.crates
.into_iter()
.map(|(name, entry)| (name, entry.version))
.collect()
};
print!("{}", format_search_hits(&hits, &installed));
if hits.iter().any(|h| installed.contains_key(&h.name)) {
println!("* installed under {}", prefix.display());
}
Ok(())
}
fn cmd_completions(shell: clap_complete::Shell) {
use clap::CommandFactory;
let mut cmd = Cli::command();
clap_complete::generate(shell, &mut cmd, "cargo-lbin", &mut std::io::stdout());
}
const DOWNGRADE_CHOICES: usize = 10;
fn parse_choice(answer: &str, count: usize) -> Result<Option<usize>> {
let answer = answer.trim();
if answer.is_empty() || answer.eq_ignore_ascii_case("q") {
return Ok(None);
}
let n: usize = answer
.parse()
.with_context(|| format!("`{answer}` is not a number between 1 and {count}"))?;
if n == 0 || n > count {
bail!("`{n}` is not between 1 and {count}");
}
Ok(Some(n - 1))
}
fn cmd_downgrade(prefix: &Path, name: &str) -> Result<()> {
validate_name(name)?;
let entry = {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
Manifest::load(prefix)?
.crates
.remove(name)
.with_context(|| format!("`{name}` is not installed under {}", prefix.display()))?
};
let current = Version::parse(&entry.version)
.with_context(|| format!("manifest holds unparsable version for `{name}`"))?;
let releases = index::releases(name)?.ok_or_else(|| index::not_found(name))?;
let candidates = index::downgrade_candidates(&releases, ¤t);
if candidates.is_empty() {
println!("{name} {current} is installed; no older version to go back to");
return Ok(());
}
if !std::io::stdin().is_terminal() {
bail!(
"downgrade asks which version to install; without a terminal, use `cargo lbin install {name}@VERSION`"
);
}
println!("{name} {current} is installed; older versions on crates.io:");
let shown = &candidates[..candidates.len().min(DOWNGRADE_CHOICES)];
for (i, v) in shown.iter().enumerate() {
println!(" {}) {v}", i + 1);
}
if candidates.len() > shown.len() {
println!(
" and {} older; use `cargo lbin install {name}@VERSION` for one of those",
candidates.len() - shown.len()
);
}
print!(
"select a version to install (1-{}), or Enter/q to abort: ",
shown.len()
);
std::io::stdout().flush()?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
let Some(pick) = parse_choice(&answer, shown.len())? else {
println!("aborted");
return Ok(());
};
let version = &shown[pick];
let cache = cache_dir()?;
let _lock = StateLock::acquire(prefix, &Mode::Exclusive)?;
let mut manifest = Manifest::load(prefix)?;
let fresh = manifest.crates.get(name).with_context(|| {
format!("`{name}` was removed while a version was being chosen; run the command again")
})?;
let fresh_version = Version::parse(&fresh.version)
.with_context(|| format!("manifest holds unparsable version for `{name}`"))?;
if fresh_version != current {
bail!(
"`{name}` changed from {current} to {fresh_version} while a version was being chosen; \
run the command again"
);
}
let locked = fresh.locked;
println!("downgrading {name} {current} -> {version}");
install_and_commit(prefix, &cache, &mut manifest, name, Some(version), locked)
}
fn cmd_checkupdate(prefix: &Path, json: bool) -> ExitCode {
let outcome = (|| {
let manifest = {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
Manifest::load(prefix)?
};
Report::new(prefix, check_versions(&manifest.crates)?)
})();
match outcome {
Ok(report) => {
if let Err(e) = cache_dir().and_then(|cache| report.store(&cache)) {
eprintln!("warning: could not save update report: {e:#}");
}
let any = report.crates.iter().any(Checked::is_outdated);
if json {
if let Err(e) = json::print(&json::CheckOutput::from_report(&report)) {
eprintln!("error: {e:#}");
return ExitCode::from(EXIT_ERROR);
}
} else {
for o in report.crates.iter().filter(|c| c.is_outdated()) {
println!("{} {} -> {}", o.name, o.current, o.latest);
}
}
if any {
ExitCode::from(EXIT_UPDATES)
} else {
ExitCode::from(EXIT_NO_UPDATES)
}
}
Err(e) => {
eprintln!("error: {e:#}");
ExitCode::from(EXIT_ERROR)
}
}
}
fn confirm(prompt: &str) -> Result<bool> {
print!("{prompt} [y/N] ");
std::io::stdout().flush()?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
Ok(matches!(answer.trim(), "y" | "Y" | "yes"))
}
fn cmd_update(prefix: &Path, crates: &[String], all: bool, yes: bool) -> Result<()> {
for name in crates {
validate_name(name)?;
}
let cache = cache_dir()?;
let snapshot = {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
Manifest::load(prefix)?
};
let skipped_pinned: Vec<&str> = if all {
snapshot
.crates
.iter()
.filter(|(_, entry)| entry.pinned)
.map(|(name, _)| name.as_str())
.collect()
} else {
Vec::new()
};
let targets: BTreeSet<String> = if all {
snapshot
.crates
.iter()
.filter(|(_, entry)| !entry.pinned)
.map(|(name, _)| name.clone())
.collect()
} else {
let targets = select_targets(&snapshot, crates)?;
refuse_pinned(&snapshot, crates)?;
targets
};
for name in &skipped_pinned {
println!(
"{name} {} [pinned, skipped]",
snapshot.crates[*name].version
);
}
let outdated: Vec<Checked> = check_versions(
snapshot
.crates
.iter()
.filter(|(name, _)| targets.contains(name.as_str())),
)?
.into_iter()
.filter(Checked::is_outdated)
.collect();
if !all {
for name in &targets {
if !outdated.iter().any(|o| &o.name == name) {
let version = snapshot.crates[name].version.as_str();
println!("{name} {version} is up to date");
}
}
}
if outdated.is_empty() {
if all && skipped_pinned.is_empty() {
println!("everything is up to date");
} else if all {
println!(
"nothing to update; {} pinned crate(s) skipped",
skipped_pinned.len()
);
}
return Ok(());
}
for o in &outdated {
println!("{} {} -> {}", o.name, o.current, o.latest);
}
if !yes && !confirm("proceed with update?")? {
println!("aborted");
return Ok(());
}
apply_updates(prefix, &cache, &outdated)
}
fn apply_updates(prefix: &Path, cache: &Path, outdated: &[Checked]) -> Result<()> {
let _lock = StateLock::acquire(prefix, &Mode::Exclusive)?;
let mut manifest = Manifest::load(prefix)?;
let total = outdated.len();
let mut updated = 0usize;
let mut skipped: Vec<&str> = Vec::new();
let mut failed: Vec<&str> = Vec::new();
for (i, o) in outdated.iter().enumerate() {
println!("[{}/{total}] {}", i + 1, o.name);
match manifest.crates.get(&o.name) {
Some(entry) if entry.version == o.current.to_string() && !entry.pinned => {
let locked = entry.locked;
match install_and_commit(prefix, cache, &mut manifest, &o.name, None, locked) {
Ok(()) => updated += 1,
Err(err) => {
eprintln!("error: updating `{}` failed: {err:#}", o.name);
failed.push(&o.name);
}
}
}
_ => {
eprintln!(
"skipping `{}`: state changed since the update was confirmed",
o.name
);
skipped.push(&o.name);
}
}
}
println!("updated {updated} of {total}");
let mut shortfall = Vec::new();
if !failed.is_empty() {
shortfall.push(format!("failed: {}", failed.join(", ")));
}
if !skipped.is_empty() {
shortfall.push(format!("skipped: {}", skipped.join(", ")));
}
if !shortfall.is_empty() {
bail!(
"{} of {total} updates not applied ({})",
total - updated,
shortfall.join("; ")
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest_with(names: &[&str]) -> Manifest {
let mut m = Manifest::default();
for n in names {
m.crates.insert(
(*n).to_owned(),
Entry {
version: "1.0.0".to_owned(),
bins: vec![(*n).to_owned()],
locked: false,
pinned: false,
},
);
}
m
}
#[test]
fn cli_shape_is_verified() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
#[test]
fn update_requires_explicit_selection() {
assert!(Cli::try_parse_from(["cargo-lbin", "update"]).is_err());
assert!(Cli::try_parse_from(["cargo-lbin", "update", "--all", "foo"]).is_err());
assert!(Cli::try_parse_from(["cargo-lbin", "update", "--all"]).is_ok());
assert!(Cli::try_parse_from(["cargo-lbin", "list", "--json"]).is_ok());
assert!(Cli::try_parse_from(["cargo-lbin", "checkupdate", "--json"]).is_ok());
assert!(Cli::try_parse_from(["cargo-lbin", "--json", "list"]).is_err());
assert!(Cli::try_parse_from(["cargo-lbin", "install", "--json", "bat"]).is_err());
assert!(Cli::try_parse_from(["cargo-lbin", "update", "foo", "bar", "-y"]).is_ok());
assert!(Cli::try_parse_from(["cargo-lbin", "lbin", "update", "--all"]).is_err());
}
#[test]
fn search_rows_align_and_mark_installed() {
let hits = [
api::Hit {
name: "scx_beerland".to_owned(),
version: "1.1.3".to_owned(),
description: "A sched_ext scheduler".to_owned(),
},
api::Hit {
name: "bat".to_owned(),
version: "0.26.0".to_owned(),
description: "x".repeat(SEARCH_DESCRIPTION_WIDTH + 5),
},
];
let installed = BTreeMap::from([("bat".to_owned(), "0.25.0".to_owned())]);
let out = format_search_hits(&hits, &installed);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 2);
assert!(
lines[0].starts_with(" scx_beerland 1.1.3 A sched_ext scheduler"),
"{}",
lines[0]
);
assert!(
lines[1].starts_with("* bat 0.26.0 "),
"{}",
lines[1]
);
assert!(lines[1].ends_with(" [installed 0.25.0]"), "{}", lines[1]);
let desc = lines[1].rsplit(" ").nth(1).unwrap();
assert_eq!(desc.chars().count(), SEARCH_DESCRIPTION_WIDTH + 1);
assert!(desc.ends_with('…'));
}
#[test]
fn info_describes_installed_state_with_checkupdate_rules() {
let rel = |v: &str, yanked: bool| index::Release {
version: Version::parse(v).unwrap(),
yanked,
};
let releases = [
rel("1.0.0", false),
rel("1.1.0", true),
rel("1.2.0", false),
rel("2.0.0-rc.1", false),
];
let m = manifest_with(&["foo"]);
let installed = m.crates.get("foo");
let out = describe_info("foo", &releases, installed);
assert!(out.contains("latest: 1.2.0"), "{out}");
assert!(out.contains("pre-release: 2.0.0-rc.1"), "{out}");
assert!(out.contains("releases: 4 (1 yanked)"), "{out}");
assert!(
out.contains("installed: 1.0.0 (update available: 1.2.0)"),
"{out}"
);
let out = describe_info("foo", &releases, None);
assert!(out.contains("installed: no"), "{out}");
let mut m = manifest_with(&["foo"]);
m.crates.get_mut("foo").unwrap().version = "1.2.0".to_owned();
let out = describe_info("foo", &releases, m.crates.get("foo"));
assert!(out.contains("installed: 1.2.0 (up to date)"), "{out}");
let releases = [rel("1.0.0", false), rel("1.1.0", true)];
let out = describe_info("foo", &releases, installed);
assert!(out.contains("latest: 1.1.0 [yanked]"), "{out}");
assert!(out.contains("installed: 1.0.0 (up to date)"), "{out}");
let releases = [rel("1.0.0", true)];
let out = describe_info("foo", &releases, installed);
assert!(out.contains("latest: 1.0.0 [yanked]"), "{out}");
assert!(
out.contains("installed: 1.0.0 (no non-yanked releases)"),
"{out}"
);
}
#[test]
fn pinned_crates_are_refused_by_name_all_at_once() {
let mut m = manifest_with(&["bat", "fd", "ripgrep"]);
m.crates.get_mut("bat").unwrap().pinned = true;
m.crates.get_mut("fd").unwrap().pinned = true;
let err = refuse_pinned(&m, &["ripgrep".into(), "bat".into(), "fd".into()])
.unwrap_err()
.to_string();
assert!(err.contains("bat") && err.contains("fd"), "{err}");
assert!(!err.contains("ripgrep"), "{err}");
assert!(err.contains("`cargo lbin unpin bat fd`"), "{err}");
assert!(refuse_pinned(&m, &["ripgrep".into(), "nope".into()]).is_ok());
}
#[test]
fn completions_cover_every_shell_and_every_command() {
use clap::{CommandFactory, ValueEnum};
let names: Vec<String> = Cli::command()
.get_subcommands()
.map(|c| c.get_name().to_owned())
.collect();
assert_ne!(names, Vec::<String>::new());
for shell in clap_complete::Shell::value_variants() {
let mut out = Vec::new();
clap_complete::generate(*shell, &mut Cli::command(), "cargo-lbin", &mut out);
let script = String::from_utf8(out).unwrap();
assert!(!script.is_empty(), "{shell}");
for name in &names {
assert!(script.contains(name.as_str()), "{shell}: missing `{name}`");
}
}
}
#[test]
fn downgrade_choice_is_a_number_or_nothing() {
assert_eq!(parse_choice("2", 3).unwrap(), Some(1));
assert_eq!(parse_choice(" 3\n", 3).unwrap(), Some(2));
assert_eq!(parse_choice("", 3).unwrap(), None);
assert_eq!(parse_choice("\n", 3).unwrap(), None);
assert_eq!(parse_choice("q", 3).unwrap(), None);
assert_eq!(parse_choice("Q", 3).unwrap(), None);
for bad in ["0", "4", "-1", "1.1.2", "one", "1 2"] {
assert!(parse_choice(bad, 3).is_err(), "{bad}");
}
}
#[test]
fn downgrade_command_takes_one_name() {
assert!(Cli::try_parse_from(["cargo-lbin", "downgrade", "bat"]).is_ok());
assert!(Cli::try_parse_from(["cargo-lbin", "downgrade"]).is_err());
assert!(Cli::try_parse_from(["cargo-lbin", "downgrade", "bat", "fd"]).is_err());
assert!(Cli::try_parse_from(["cargo-lbin", "downgrade", "bat", "--yes"]).is_err());
}
#[test]
fn pin_commands_parse() {
assert!(Cli::try_parse_from(["cargo-lbin", "pin", "bat"]).is_ok());
assert!(Cli::try_parse_from(["cargo-lbin", "unpin", "bat", "fd"]).is_ok());
assert!(Cli::try_parse_from(["cargo-lbin", "pin"]).is_err());
}
#[test]
fn select_targets_reports_all_unknown_names_at_once() {
let m = manifest_with(&["foo", "bar"]);
let err = select_targets(&m, &["foo".into(), "nope".into(), "nada".into()])
.unwrap_err()
.to_string();
assert!(err.contains("nope") && err.contains("nada"), "{err}");
assert!(!err.contains("foo"), "{err}");
}
#[test]
fn select_targets_collapses_duplicates() {
let m = manifest_with(&["foo", "bar"]);
let targets = select_targets(&m, &["bar".into(), "foo".into(), "bar".into()]).unwrap();
assert_eq!(targets.into_iter().collect::<Vec<_>>(), ["bar", "foo"]);
}
#[test]
fn commit_entry_restores_memory_on_store_failure() {
let tmp = std::env::temp_dir().join("cargo-lbin-test-commit-entry");
let _ = std::fs::remove_dir_all(&tmp);
let prefix = tmp.join("prefix");
std::fs::create_dir_all(prefix.join("share")).unwrap();
std::fs::write(prefix.join("share/cargo-lbin"), b"").unwrap();
let entry = |v: &str| Entry {
version: v.to_owned(),
bins: vec!["foo".to_owned()],
locked: false,
pinned: false,
};
let mut m = manifest_with(&["foo"]);
assert!(commit_entry(&mut m, &prefix, "foo", entry("2.0.0")).is_err());
assert_eq!(m.crates["foo"].version, "1.0.0");
let mut m = Manifest::default();
assert!(commit_entry(&mut m, &prefix, "foo", entry("2.0.0")).is_err());
assert_eq!(m.crates.keys().collect::<Vec<_>>(), Vec::<&String>::new());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn obsolete_is_old_minus_new() {
let old = vec!["foo".to_owned(), "fooctl".to_owned()];
let new = vec!["foo".to_owned()];
assert_eq!(obsolete_bins(&old, &new), vec!["fooctl".to_owned()]);
assert_eq!(obsolete_bins(&new, &old), [] as [String; 0]);
assert_eq!(obsolete_bins(&old, &old), [] as [String; 0]);
}
#[test]
fn newly_introduced_is_new_minus_old() {
let old = vec!["foo".to_owned()];
let new = vec!["foo".to_owned(), "fooctl".to_owned()];
assert_eq!(newly_introduced_bins(&old, &new), vec!["fooctl".to_owned()]);
assert_eq!(newly_introduced_bins(&[], &new), new);
assert_eq!(newly_introduced_bins(&new, &new), [] as [String; 0]);
}
#[test]
fn rollback_set_tracks_only_new_names_actually_placed() {
let mut manifest = Manifest::default();
manifest.crates.insert(
"foo".to_owned(),
Entry {
version: "1.0.0".to_owned(),
bins: vec!["foo".to_owned()],
locked: false,
pinned: false,
},
);
let new_bins = vec!["foo".to_owned(), "fooctl".to_owned(), "fooadmin".to_owned()];
let bin_dir = Path::new("/nonexistent/bin");
let mut set = RollbackSet::snapshot(&manifest, "foo", &new_bins);
set.note_placed("foo", bin_dir.join("foo"));
assert_eq!(set.placed, [] as [PathBuf; 0]);
set.note_placed("fooctl", bin_dir.join("fooctl"));
assert_eq!(set.placed, vec![bin_dir.join("fooctl")]);
let fresh = RollbackSet::snapshot(&Manifest::default(), "bar", &new_bins);
assert_eq!(fresh.new_names, new_bins);
assert_eq!(fresh.placed, [] as [PathBuf; 0]);
}
#[test]
fn collisions_are_detected_before_placement() {
let dir = std::env::temp_dir().join("cargo-lbin-test-collision");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut manifest = Manifest::default();
manifest.crates.insert(
"owner".to_owned(),
Entry {
version: "1.0.0".to_owned(),
bins: vec!["shared".to_owned()],
locked: false,
pinned: false,
},
);
assert!(check_collisions(&manifest, "owner", &["shared".to_owned()], &dir).is_ok());
let err = check_collisions(&manifest, "intruder", &["shared".to_owned()], &dir)
.unwrap_err()
.to_string();
assert!(
err.contains("owner"),
"error should name the owning crate: {err}"
);
std::fs::write(dir.join("stray"), b"").unwrap();
assert!(check_collisions(&manifest, "newcrate", &["stray".to_owned()], &dir).is_err());
assert!(check_collisions(&manifest, "newcrate", &["fresh".to_owned()], &dir).is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
}