mod ui;
use std::collections::{BTreeMap, VecDeque};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
use std::thread;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use ratatui::DefaultTerminal;
use ratatui::crossterm::ExecutableCommand;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::crossterm::terminal::{EnterAlternateScreen, enable_raw_mode};
use semver::Version;
use crate::api;
use crate::lock::{Mode, StateLock};
use crate::manifest::{Entry, Manifest};
use crate::progress;
use crate::report::{Checked, Report, Status};
use crate::validate::InstallSpec;
const TICK: Duration = Duration::from_millis(100);
pub struct Row {
pub name: String,
pub version: String,
pub bins: Vec<String>,
pub locked: bool,
pub pinned: bool,
pub also: String,
pub status: RowStatus,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum RowStatus {
UpToDate,
Outdated(Version),
Unknown,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Filter {
All,
Updates,
Pinned,
}
impl Filter {
pub const ALL: [Filter; 3] = [Filter::All, Filter::Updates, Filter::Pinned];
pub fn index(self) -> usize {
match self {
Filter::All => 0,
Filter::Updates => 1,
Filter::Pinned => 2,
}
}
fn next(self) -> Self {
match self {
Filter::All => Filter::Updates,
Filter::Updates => Filter::Pinned,
Filter::Pinned => Filter::All,
}
}
fn prev(self) -> Self {
match self {
Filter::All => Filter::Pinned,
Filter::Updates => Filter::All,
Filter::Pinned => Filter::Updates,
}
}
}
fn admits(filter: Filter, row: &Row) -> bool {
match filter {
Filter::All => true,
Filter::Updates => matches!(row.status, RowStatus::Outdated(_)) && !row.pinned,
Filter::Pinned => row.pinned,
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum MessageKind {
Info,
Warning,
Error,
}
pub struct Message {
pub text: String,
pub kind: MessageKind,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum InputPurpose {
Install,
Search,
}
pub struct Input {
pub purpose: InputPurpose,
pub buffer: String,
}
pub struct Confirm {
pub prompt: String,
action: PendingAction,
}
#[derive(Clone)]
enum PendingAction {
Update(String),
UpdateAll,
Install {
crates: Vec<String>,
locked: bool,
},
Remove(String),
SetPinned {
name: String,
pinned: bool,
},
Downgrade(String),
}
fn build_msg(kind: crate::LineKind, line: &str) -> BuildMsg {
let line = crate::text::sanitize(line);
match kind {
crate::LineKind::Cargo => BuildMsg::Cargo(line),
crate::LineKind::Notice => BuildMsg::Notice(line),
crate::LineKind::Warning => BuildMsg::Warning(line),
}
}
enum BuildMsg {
Cargo(String),
Notice(String),
Warning(String),
NeedAuth,
Done(Result<()>),
}
pub struct BuildReport {
pub title: String,
pub lines: Vec<String>,
pub failed: bool,
}
const BUILD_TAIL: usize = 40;
enum Job {
Check(Receiver<Result<Vec<Checked>>>),
Search {
query: String,
rx: Receiver<Result<Vec<api::Hit>>>,
},
Build {
name: String,
rx: Receiver<BuildMsg>,
auth_tx: Sender<bool>,
units_started: usize,
current: Option<String>,
tail: VecDeque<String>,
status_note: Option<String>,
warnings: Vec<String>,
started: std::time::Instant,
needs_auth: bool,
},
}
impl Job {
fn label(&self) -> String {
match self {
Job::Check(_) => "checking crates.io for updates…".to_owned(),
Job::Search { query, .. } => format!("searching crates.io for `{query}`…"),
Job::Build { name, .. } => format!("building {name}…"),
}
}
}
const SEARCH_HITS: usize = 6;
pub struct SearchResult {
pub query: String,
pub hits: Vec<api::Hit>,
pub installed: BTreeMap<String, String>,
}
pub struct App {
prefix: PathBuf,
cache: PathBuf,
rows: Vec<Row>,
pub report_age: Option<Duration>,
pub filter: Filter,
pub selected: usize,
pub message: Option<Message>,
pub input: Option<Input>,
pub confirm: Option<Confirm>,
pub search_result: Option<SearchResult>,
pub build_report: Option<BuildReport>,
pub show_help: bool,
pending: Option<PendingAction>,
pending_build: Option<(String, bool)>,
job: Option<Job>,
ticks: usize,
should_quit: bool,
}
pub fn run(prefix: &Path) -> Result<()> {
let mut app = App::new(prefix)?;
let mut terminal = match ratatui::try_init() {
Ok(terminal) => terminal,
Err(e) => {
let _ = ratatui::try_restore();
return Err(e).context("initializing the TUI");
}
};
let run_result = app.run(&mut terminal);
let cursor_result = terminal.show_cursor();
let restore_result = ratatui::try_restore();
run_result?;
cursor_result.context("showing the cursor on exit")?;
restore_result.context("restoring the terminal on exit")?;
Ok(())
}
impl App {
fn new(prefix: &Path) -> Result<Self> {
let mut app = Self {
prefix: prefix.to_path_buf(),
cache: crate::cache_dir()?,
rows: Vec::new(),
report_age: None,
filter: Filter::All,
selected: 0,
message: None,
input: None,
confirm: None,
search_result: None,
build_report: None,
pending_build: None,
ticks: 0,
show_help: false,
pending: None,
job: None,
should_quit: false,
};
app.reload()?;
Ok(app)
}
fn reload(&mut self) -> Result<()> {
self.search_result = None;
let report = match Report::load(&self.cache, &self.prefix) {
Ok(report) => report,
Err(e) => {
self.warn(&format!("update report unreadable: {e:#}"));
None
}
};
self.apply_report(report.as_ref())
}
fn apply_report(&mut self, report: Option<&Report>) -> Result<()> {
let manifest = {
let _lock = StateLock::acquire(&self.prefix, &Mode::Shared)?;
Manifest::load(&self.prefix)?
};
let also = crate::prefixes::also_installed(&self.prefix);
self.rows = rows_from(&manifest, report, &also);
self.report_age = report.map(Report::age);
self.clamp_selection();
Ok(())
}
pub fn visible(&self) -> Vec<&Row> {
self.rows
.iter()
.filter(|r| admits(self.filter, r))
.collect()
}
pub fn selected_row(&self) -> Option<&Row> {
self.visible().get(self.selected).copied()
}
pub fn updates_available(&self) -> usize {
self.rows
.iter()
.filter(|r| matches!(r.status, RowStatus::Outdated(_)) && !r.pinned)
.count()
}
pub fn pinned_count(&self) -> usize {
self.rows.iter().filter(|r| r.pinned).count()
}
pub fn pinned_outdated(&self) -> usize {
self.rows
.iter()
.filter(|r| r.pinned && matches!(r.status, RowStatus::Outdated(_)))
.count()
}
pub fn total(&self) -> usize {
self.rows.len()
}
pub fn not_checked(&self) -> usize {
self.rows
.iter()
.filter(|r| r.status == RowStatus::Unknown)
.count()
}
pub fn busy(&self) -> Option<String> {
self.job.as_ref().map(Job::label)
}
pub fn build_progress(&self) -> Option<String> {
const FRAMES: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
let Some(Job::Build {
name,
units_started,
current,
status_note,
started,
..
}) = &self.job
else {
return None;
};
let frame = FRAMES[self.ticks % FRAMES.len()];
let elapsed = format_elapsed(started.elapsed());
if let Some(note) = status_note {
return Some(format!("{frame} {name}: {note} · elapsed {elapsed}"));
}
let unit_word = if *units_started == 1 { "unit" } else { "units" };
let mut line = format!("{frame} building {name} · {units_started} {unit_word}");
if let Some(current) = current {
let _ = write!(line, " · compiling {current}");
}
let _ = write!(line, " · elapsed {elapsed}");
Some(line)
}
pub fn prefix(&self) -> &Path {
&self.prefix
}
fn clamp_selection(&mut self) {
let len = self.visible().len();
self.selected = if len == 0 {
0
} else {
self.selected.min(len - 1)
};
}
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
while !self.should_quit {
self.ticks = self.ticks.wrapping_add(1);
terminal.draw(|frame| ui::draw(frame, self))?;
if let Some(action) = self.pending.take() {
self.run_in_terminal(terminal, &action)?;
continue;
}
if let Some((spec, locked)) = self.pending_build.take() {
self.start_build(terminal, &spec, locked)?;
continue;
}
if matches!(
&self.job,
Some(Job::Build {
needs_auth: true,
..
})
) {
self.answer_auth(terminal)?;
}
if event::poll(TICK)?
&& let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
self.on_key(key);
}
self.poll_job()?;
}
Ok(())
}
fn run_in_terminal(
&mut self,
terminal: &mut DefaultTerminal,
action: &PendingAction,
) -> Result<()> {
terminal.show_cursor()?;
ratatui::try_restore().context("leaving the TUI")?;
println!();
let outcome = match action {
PendingAction::Update(name) => {
crate::cmd_update(&self.prefix, std::slice::from_ref(name), false, false)
}
PendingAction::UpdateAll => crate::cmd_update(&self.prefix, &[], true, false),
PendingAction::Install { crates, locked } => {
crate::cmd_install(&self.prefix, crates, *locked)
}
PendingAction::Remove(name) => {
crate::cmd_remove(&self.prefix, std::slice::from_ref(name))
}
PendingAction::SetPinned { name, pinned } => {
crate::cmd_set_pinned(&self.prefix, std::slice::from_ref(name), *pinned)
}
PendingAction::Downgrade(name) => crate::cmd_downgrade(&self.prefix, name),
};
if let Err(e) = &outcome {
eprintln!("error: {e:#}");
}
eprint!("\n[press Enter to return] ");
let _ = std::io::stdin().read_line(&mut String::new());
enable_raw_mode().context("re-entering raw mode")?;
std::io::stdout()
.execute(EnterAlternateScreen)
.context("re-entering the alternate screen")?;
terminal.clear()?;
self.reload()?;
match outcome {
Ok(()) => self.info(&format!("{} finished", action_label(action))),
Err(e) => self.error(&format!("{} failed: {e:#}", action_label(action))),
}
Ok(())
}
fn start_build(
&mut self,
terminal: &mut DefaultTerminal,
raw_spec: &str,
locked: bool,
) -> Result<()> {
let spec = match InstallSpec::parse_all(std::slice::from_ref(&raw_spec.to_owned())) {
Ok(mut specs) => specs.remove(0),
Err(e) => {
self.error(&format!("{e:#}"));
return Ok(());
}
};
if self.refused_by_advisory_pin_check(&spec) {
return Ok(());
}
self.build_report = None;
let policy = crate::privileged::Policy::for_prefix(&self.prefix);
let escalate = match crate::install_needs_privilege(policy, &self.prefix) {
Ok(escalate) => escalate,
Err(e) => {
self.error(&format!("{e:#}"));
return Ok(());
}
} || (matches!(policy.sudo, crate::privileged::Sudo::Allowed)
&& StateLock::preparation_needs_privilege(&self.prefix));
if escalate {
let fresh = match crate::privileged::credentials_fresh() {
Ok(fresh) => fresh,
Err(e) => {
self.error(&format!("{e:#}"));
return Ok(());
}
};
let prefix = self.prefix.clone();
if !fresh
&& let Err(e) =
Self::suspended(terminal, || crate::privileged::preauthorize(&prefix, true))?
{
self.error(&format!("{e:#}"));
return Ok(());
}
match crate::privileged::credentials_fresh() {
Ok(true) => {}
Ok(false) => {
self.info("sudo does not cache credentials here; handing the terminal over");
self.pending = Some(PendingAction::Install {
crates: vec![raw_spec.to_owned()],
locked,
});
return Ok(());
}
Err(e) => {
self.error(&format!("{e:#}"));
return Ok(());
}
}
}
let (tx, rx) = mpsc::channel();
let (auth_tx, auth_rx) = mpsc::channel();
let prefix = self.prefix.clone();
let name = spec.name.clone();
let worker_tx = tx.clone();
std::thread::spawn(move || {
let line_tx = worker_tx.clone();
let result = crate::tui_install_one(
&prefix,
&spec,
locked,
&mut |k: crate::LineKind, l: &str| {
let _ = line_tx.send(build_msg(k, l));
},
&mut || match crate::privileged::credentials_fresh() {
Ok(true) => Ok(()),
Ok(false) => {
let _ = worker_tx.send(BuildMsg::NeedAuth);
match auth_rx.recv() {
Ok(true) => Ok(()),
Ok(false) => anyhow::bail!("sudo authentication failed"),
Err(_) => anyhow::bail!("the interface went away mid-authorization"),
}
}
Err(e) => Err(e),
},
);
let _ = tx.send(BuildMsg::Done(result));
});
self.job = Some(Job::Build {
name,
rx,
auth_tx,
units_started: 0,
current: None,
tail: VecDeque::new(),
status_note: None,
warnings: Vec::new(),
started: std::time::Instant::now(),
needs_auth: false,
});
Ok(())
}
fn answer_auth(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
let prefix = self.prefix.clone();
let outcome = Self::suspended(terminal, || crate::privileged::preauthorize(&prefix, true))?;
let ok = match outcome {
Ok(()) => match crate::privileged::credentials_fresh() {
Ok(true) => true,
Ok(false) => {
self.warn(
"sudo did not retain credentials; noninteractive placement cannot proceed",
);
false
}
Err(e) => {
self.warn(&format!("{e:#}"));
false
}
},
Err(e) => {
self.warn(&format!("{e:#}"));
false
}
};
if let Some(Job::Build {
auth_tx,
needs_auth,
..
}) = &mut self.job
{
*needs_auth = false;
let _ = auth_tx.send(ok);
}
Ok(())
}
fn refused_by_advisory_pin_check(&mut self, spec: &InstallSpec) -> bool {
if spec.version.is_some() {
return false;
}
let advisory = StateLock::try_acquire_with(
&self.prefix,
&Mode::Shared,
crate::privileged::Policy::for_prefix(&self.prefix).screen_owned(),
&mut |_| {},
);
if let Ok(Some(_lock)) = advisory {
match Manifest::load(&self.prefix) {
Ok(m) if m.crates.get(&spec.name).is_some_and(|e| e.pinned) => {
self.error(&format!(
"{} is pinned; `p` unpins it, or name a version to re-pin",
spec.name
));
return true;
}
Ok(_) => {}
Err(e) => {
self.error(&format!("{e:#}"));
return true;
}
}
}
false
}
fn suspended<T>(terminal: &mut DefaultTerminal, f: impl FnOnce() -> T) -> Result<T> {
terminal.show_cursor()?;
ratatui::try_restore().context("leaving the TUI")?;
println!();
let value = f();
enable_raw_mode().context("re-entering raw mode")?;
std::io::stdout()
.execute(EnterAlternateScreen)
.context("re-entering the alternate screen")?;
terminal.clear()?;
Ok(value)
}
fn finish_build(
&mut self,
name: &str,
result: Result<()>,
tail: &VecDeque<String>,
warnings: Vec<String>,
) {
let reload = self.reload();
match result {
Ok(()) => {
let note = tail
.iter()
.rev()
.find(|l| l.starts_with("installed "))
.cloned()
.unwrap_or_else(|| format!("install {name} finished"));
if warnings.is_empty() {
match reload {
Ok(()) => self.info(¬e),
Err(e) => self.error(&format!("{note} — but reload failed: {e:#}")),
}
} else {
let mut lines = warnings;
if let Err(e) = &reload {
lines.push(String::new());
lines.push(crate::text::sanitize(&format!(
"(and the list reload failed: {e:#})"
)));
}
self.build_report = Some(BuildReport {
title: format!("install {name}: warnings"),
lines,
failed: false,
});
self.warn(&format!(
"{note} — with warnings in the panel; Esc/Enter dismisses"
));
}
}
Err(e) => {
let text = format!("{e:#}");
let mut lines: Vec<String> = text.lines().map(crate::text::sanitize).collect();
if lines.len() <= 1 {
lines.extend(tail.iter().rev().take(8).rev().cloned());
}
if let Err(re) = reload {
lines.push(crate::text::sanitize(&format!(
"(and the list reload failed: {re:#})"
)));
}
self.build_report = Some(BuildReport {
title: format!("install {name} failed"),
lines,
failed: true,
});
self.error(&format!(
"install {name} failed — details in the panel; Esc/Enter dismisses"
));
}
}
}
fn on_key(&mut self, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
self.should_quit = true;
return;
}
if self.show_help {
self.show_help = false;
return;
}
if self.build_report.is_some()
&& self.input.is_none()
&& self.confirm.is_none()
&& matches!(key.code, KeyCode::Esc | KeyCode::Enter)
{
self.build_report = None;
self.message = None;
return;
}
if self.confirm.is_some() {
self.on_key_confirm(key);
} else if self.input.is_some() {
self.on_key_input(key);
} else {
self.on_key_list(key);
}
}
fn on_key_list(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('q') => {
if matches!(self.job, Some(Job::Build { .. })) {
self.error("a build is running; Ctrl-C abandons it");
} else {
self.should_quit = true;
}
}
KeyCode::Esc => {
if self.search_result.take().is_some() {
} else if matches!(self.job, Some(Job::Build { .. })) {
self.error("a build is running; Ctrl-C abandons it");
} else {
self.should_quit = true;
}
}
KeyCode::Char('?') => self.show_help = true,
KeyCode::Down | KeyCode::Char('j') => self.select_next(),
KeyCode::Up | KeyCode::Char('k') => self.select_prev(),
KeyCode::Home | KeyCode::Char('g') => self.selected = 0,
KeyCode::End | KeyCode::Char('G') => {
self.selected = self.visible().len().saturating_sub(1);
}
KeyCode::Tab => {
self.filter = self.filter.next();
self.clamp_selection();
}
KeyCode::BackTab => {
self.filter = self.filter.prev();
self.clamp_selection();
}
KeyCode::Char('r') => self.start_check(),
KeyCode::Char('s') => self.open_input(InputPurpose::Search),
KeyCode::Char(c @ '1'..='9') if self.search_result.is_some() => {
let pick = c
.to_digit(10)
.and_then(|d| usize::try_from(d).ok())
.and_then(|d| d.checked_sub(1))
.and_then(|i| self.search_result.as_ref()?.hits.get(i))
.map(|h| h.name.clone());
if let Some(name) = pick {
self.input = Some(Input {
purpose: InputPurpose::Install,
buffer: name,
});
}
}
KeyCode::Char('i') => self.open_input(InputPurpose::Install),
KeyCode::Enter | KeyCode::Char('u') => {
if let Some(name) = self.selected_name() {
self.queue(PendingAction::Update(name));
}
}
KeyCode::Char('U') => self.queue(PendingAction::UpdateAll),
KeyCode::Char('p') => {
if let Some(row) = self.selected_row() {
self.queue(PendingAction::SetPinned {
name: row.name.clone(),
pinned: !row.pinned,
});
}
}
KeyCode::Char('D') => {
if let Some(name) = self.selected_name() {
self.queue(PendingAction::Downgrade(name));
}
}
KeyCode::Char('x') => {
if let Some(row) = self.selected_row() {
let prompt = format!("remove {} ({})? [y/N]", row.name, row.bins.join(", "));
self.confirm = Some(Confirm {
prompt,
action: PendingAction::Remove(row.name.clone()),
});
}
}
_ => {}
}
}
fn on_key_confirm(&mut self, key: KeyEvent) {
let Some(confirm) = self.confirm.take() else {
return;
};
if matches!(key.code, KeyCode::Char('y' | 'Y')) {
self.queue(confirm.action);
} else {
self.info("cancelled");
}
}
fn on_key_input(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => self.input = None,
KeyCode::Enter => {
if let Some(Input { purpose, buffer }) = self.input.take() {
self.submit_input(purpose, &buffer);
}
}
KeyCode::Backspace => {
if let Some(input) = self.input.as_mut() {
input.buffer.pop();
}
}
KeyCode::Char(c) if !c.is_control() => {
if let Some(input) = self.input.as_mut() {
input.buffer.push(c);
}
}
_ => {}
}
}
fn submit_input(&mut self, purpose: InputPurpose, buffer: &str) {
match purpose {
InputPurpose::Install => match parse_install_input(buffer) {
Ok((crates, locked)) if crates.len() == 1 => {
let spec = crates.into_iter().next().expect("len checked");
self.info(&format!("building {spec}…"));
self.pending_build = Some((spec, locked));
}
Ok((crates, locked)) => self.queue(PendingAction::Install { crates, locked }),
Err(e) => self.error(&format!("{e:#}")),
},
InputPurpose::Search => match parse_search_input(buffer) {
Ok(query) => self.start_search(query),
Err(e) => self.error(&format!("{e:#}")),
},
}
}
fn open_input(&mut self, purpose: InputPurpose) {
if self.job.is_some() {
self.error("busy; wait for the current job to finish");
return;
}
self.input = Some(Input {
purpose,
buffer: String::new(),
});
}
fn queue(&mut self, action: PendingAction) {
if self.job.is_some() {
self.error("busy; wait for the current job to finish");
return;
}
self.info(&format!("running {}…", action_label(&action)));
self.pending = Some(action);
}
fn start_check(&mut self) {
if self.job.is_some() {
self.error("busy; wait for the current lookup to finish");
return;
}
if let Err(e) = self.reload() {
self.error(&format!("reload failed: {e:#}"));
return;
}
if self.rows.is_empty() {
self.info("nothing installed; nothing to check");
return;
}
let entries: BTreeMap<String, Entry> = self
.rows
.iter()
.map(|r| {
(
r.name.clone(),
Entry {
version: r.version.clone(),
bins: r.bins.clone(),
locked: r.locked,
pinned: r.pinned,
},
)
})
.collect();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let _ = tx.send(crate::check_versions(&entries));
});
self.job = Some(Job::Check(rx));
self.message = None;
}
fn start_search(&mut self, query: String) {
self.search_result = None;
let (tx, rx) = mpsc::channel();
let q = query.clone();
thread::spawn(move || {
let _ = tx.send(api::search(&q, SEARCH_HITS));
});
self.job = Some(Job::Search { query, rx });
self.message = None;
}
fn poll_job(&mut self) -> Result<()> {
let Some(job) = self.job.take() else {
return Ok(());
};
match job {
Job::Check(rx) => match rx.try_recv() {
Ok(result) => self.finish_check(result),
Err(TryRecvError::Empty) => self.job = Some(Job::Check(rx)),
Err(TryRecvError::Disconnected) => {
bail!("update check worker aborted; the terminal was reset by the panic")
}
},
Job::Search { query, rx } => match rx.try_recv() {
Ok(result) => self.finish_search(query, result),
Err(TryRecvError::Empty) => self.job = Some(Job::Search { query, rx }),
Err(TryRecvError::Disconnected) => {
bail!("search worker aborted; the terminal was reset by the panic")
}
},
Job::Build {
name,
rx,
auth_tx,
mut units_started,
mut current,
mut tail,
mut status_note,
mut warnings,
started,
mut needs_auth,
} => {
let mut done: Option<Result<()>> = None;
loop {
match rx.try_recv() {
Ok(BuildMsg::Cargo(line)) => {
match progress::parse_line(&line) {
progress::BuildEvent::Compiling { name, version } => {
units_started += 1;
current = Some(format!("{name} {version}"));
}
progress::BuildEvent::Finished => current = None,
_ => {}
}
status_note = None;
tail.push_back(line);
if tail.len() > BUILD_TAIL {
tail.pop_front();
}
}
Ok(BuildMsg::Notice(line)) => {
status_note = Some(line.clone());
tail.push_back(line);
if tail.len() > BUILD_TAIL {
tail.pop_front();
}
}
Ok(BuildMsg::Warning(line)) => warnings.push(line),
Ok(BuildMsg::NeedAuth) => needs_auth = true,
Ok(BuildMsg::Done(result)) => {
done = Some(result);
break;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
bail!("build worker aborted; the terminal was reset by the panic")
}
}
}
match done {
Some(result) => self.finish_build(&name, result, &tail, warnings),
None => {
self.job = Some(Job::Build {
name,
rx,
auth_tx,
units_started,
current,
tail,
status_note,
warnings,
started,
needs_auth,
});
}
}
}
}
Ok(())
}
fn finish_check(&mut self, result: Result<Vec<Checked>>) {
let report = match result.and_then(|checked| Report::new(&self.prefix, checked)) {
Ok(report) => report,
Err(e) => {
self.error(&format!("update check failed: {e:#}"));
return;
}
};
let persisted = report.store(&self.cache);
if let Err(e) = self.apply_report(Some(&report)) {
self.error(&format!("reload failed: {e:#}"));
return;
}
let n = self.updates_available();
let held = self.pinned_outdated();
let summary = if held > 0 {
format!("{n} update(s) available; {held} pinned held back")
} else {
format!("{n} update(s) available")
};
match persisted {
Ok(()) => self.info(&format!("checked: {summary}")),
Err(e) => self.warn(&format!("checked: {summary}; report not saved: {e:#}")),
}
}
fn finish_search(&mut self, query: String, result: Result<Vec<api::Hit>>) {
match result {
Ok(hits) if hits.is_empty() => self.info(&format!("no crates match `{query}`")),
Ok(hits) => {
if let Err(e) = self.reload() {
self.error(&format!("reload failed: {e:#}"));
return;
}
let installed: BTreeMap<String, String> = self
.rows
.iter()
.filter(|r| hits.iter().any(|h| h.name == r.name))
.map(|r| (r.name.clone(), r.version.clone()))
.collect();
let n = hits.len();
self.search_result = Some(SearchResult {
query,
hits,
installed,
});
self.info(&format!("{n} hit(s); 1-{n} to install, Esc to dismiss"));
}
Err(e) => self.error(&format!("{e:#}")),
}
}
fn selected_name(&self) -> Option<String> {
self.selected_row().map(|r| r.name.clone())
}
fn select_next(&mut self) {
let len = self.visible().len();
if len > 0 && self.selected + 1 < len {
self.selected += 1;
}
}
fn select_prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
fn info(&mut self, text: &str) {
self.notify(text, MessageKind::Info);
}
fn warn(&mut self, text: &str) {
self.notify(text, MessageKind::Warning);
}
fn error(&mut self, text: &str) {
self.notify(text, MessageKind::Error);
}
fn notify(&mut self, text: &str, kind: MessageKind) {
self.message = Some(Message {
text: crate::text::sanitize(text),
kind,
});
}
}
fn action_label(action: &PendingAction) -> String {
match action {
PendingAction::Update(name) => format!("update {name}"),
PendingAction::UpdateAll => "update --all".to_owned(),
PendingAction::Install { crates, locked } => {
let mut label = format!("install {}", crates.join(" "));
if *locked {
label.push_str(" --locked");
}
label
}
PendingAction::Remove(name) => format!("remove {name}"),
PendingAction::SetPinned { name, pinned: true } => format!("pin {name}"),
PendingAction::SetPinned {
name,
pinned: false,
} => format!("unpin {name}"),
PendingAction::Downgrade(name) => format!("downgrade {name}"),
}
}
fn format_elapsed(d: std::time::Duration) -> String {
let total = d.as_secs();
let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
if h > 0 {
format!("{h}:{m:02}:{s:02}")
} else {
format!("{m:02}:{s:02}")
}
}
fn rows_from(
manifest: &Manifest,
report: Option<&Report>,
also: &std::collections::BTreeMap<String, Vec<crate::prefixes::AlsoIn>>,
) -> Vec<Row> {
manifest
.crates
.iter()
.map(|(name, entry)| {
let status = Version::parse(&entry.version)
.ok()
.and_then(|current| report?.status_for(name, ¤t))
.map_or(RowStatus::Unknown, |s| match s {
Status::UpToDate => RowStatus::UpToDate,
Status::Outdated(v) => RowStatus::Outdated(v.clone()),
});
Row {
name: name.clone(),
version: entry.version.clone(),
bins: entry.bins.clone(),
locked: entry.locked,
pinned: entry.pinned,
also: crate::prefixes::describe_for(also, name),
status,
}
})
.collect()
}
fn parse_install_input(buffer: &str) -> Result<(Vec<String>, bool)> {
let mut crates = Vec::new();
let mut locked = false;
for token in buffer.split_whitespace() {
if token == "--locked" {
locked = true;
} else {
crates.push(token.to_owned());
}
}
if crates.is_empty() {
bail!("no crate name given");
}
InstallSpec::parse_all(&crates)?;
Ok((crates, locked))
}
fn parse_search_input(buffer: &str) -> Result<String> {
let query = buffer.split_whitespace().collect::<Vec<_>>().join(" ");
if query.is_empty() {
bail!("no search terms given");
}
Ok(query)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::Checked;
fn v(s: &str) -> Version {
Version::parse(s).unwrap()
}
fn manifest(entries: &[(&str, &str)]) -> Manifest {
let mut m = Manifest::default();
for (name, version) in entries {
m.crates.insert(
(*name).to_owned(),
Entry {
version: (*version).to_owned(),
bins: vec![(*name).to_owned()],
locked: false,
pinned: false,
},
);
}
m
}
#[test]
fn rows_carry_three_way_status() {
let m = manifest(&[("bat", "0.26.0"), ("fd", "10.3.0"), ("ripgrep", "14.1.1")]);
let report = Report::new(
Path::new("/p"),
vec![
Checked {
name: "bat".to_owned(),
current: v("0.26.0"),
latest: v("0.26.1"),
},
Checked {
name: "ripgrep".to_owned(),
current: v("14.1.1"),
latest: v("14.1.1"),
},
Checked {
name: "fd".to_owned(),
current: v("10.2.0"),
latest: v("10.3.0"),
},
],
)
.unwrap();
let rows = rows_from(&m, Some(&report), &std::collections::BTreeMap::new());
let status: Vec<(&str, &RowStatus)> =
rows.iter().map(|r| (r.name.as_str(), &r.status)).collect();
assert_eq!(status[0], ("bat", &RowStatus::Outdated(v("0.26.1"))));
assert_eq!(status[1], ("fd", &RowStatus::Unknown));
assert_eq!(status[2], ("ripgrep", &RowStatus::UpToDate));
let rows = rows_from(&m, None, &std::collections::BTreeMap::new());
assert!(rows.iter().all(|r| r.status == RowStatus::Unknown));
}
#[test]
fn install_input_mirrors_cli_shape() {
assert_eq!(
parse_install_input("ripgrep --locked bat").unwrap(),
(vec!["ripgrep".to_owned(), "bat".to_owned()], true)
);
assert_eq!(
parse_install_input(" fd ").unwrap(),
(vec!["fd".to_owned()], false)
);
assert!(parse_install_input("").is_err());
assert!(parse_install_input("--locked").is_err());
assert!(parse_install_input("../evil").is_err());
assert_eq!(
parse_install_input("bat@0.26.0").unwrap(),
(vec!["bat@0.26.0".to_owned()], false)
);
assert!(parse_install_input("bat@^0.26").is_err());
assert!(parse_install_input("bat bat@0.26.0").is_err());
assert!(parse_install_input("bat@0.26.0 bat@0.25.0").is_err());
assert!(parse_install_input("bat bat").is_err());
assert!(parse_install_input("bat@0.26.0 bat@0.26.0").is_err());
}
#[test]
fn search_input_is_free_text() {
assert_eq!(parse_search_input(" bat ").unwrap(), "bat");
assert_eq!(
parse_search_input("sched ext\tscheduler").unwrap(),
"sched ext scheduler"
);
assert!(parse_search_input("").is_err());
assert!(parse_search_input(" ").is_err());
}
#[test]
fn filter_cycles_and_indexes() {
assert_eq!(Filter::All.next(), Filter::Updates);
assert_eq!(Filter::Updates.next(), Filter::Pinned);
assert_eq!(Filter::Pinned.next(), Filter::All);
for (i, f) in Filter::ALL.iter().enumerate() {
assert_eq!(f.index(), i);
assert_eq!(f.next().prev(), *f);
}
}
#[test]
fn view_membership_is_consistent() {
let row = |pinned: bool, status: RowStatus| Row {
name: "x".into(),
version: "1.0.0".into(),
bins: vec!["x".into()],
locked: false,
pinned,
also: String::new(),
status,
};
let newer = Version::new(2, 0, 0);
let pinned_behind = row(true, RowStatus::Outdated(newer.clone()));
let pinned_current = row(true, RowStatus::UpToDate);
let outdated = row(false, RowStatus::Outdated(newer));
let current = row(false, RowStatus::UpToDate);
assert!(admits(Filter::Updates, &outdated));
assert!(!admits(Filter::Updates, &pinned_behind));
assert!(!admits(Filter::Updates, ¤t));
assert!(admits(Filter::Pinned, &pinned_behind));
assert!(admits(Filter::Pinned, &pinned_current));
assert!(!admits(Filter::Pinned, &outdated));
assert!(!admits(Filter::Updates, &pinned_current));
assert!(!admits(Filter::Pinned, ¤t));
for r in [&pinned_behind, &pinned_current, &outdated, ¤t] {
assert!(admits(Filter::All, r));
}
}
#[test]
fn captured_kinds_survive_to_the_person() {
let prefix = std::env::temp_dir().join("cargo-lbin-test-tui-kinds");
let _ = std::fs::remove_dir_all(&prefix);
std::fs::create_dir_all(&prefix).unwrap();
let mut app = App::new(&prefix).unwrap();
let (tx, rx) = mpsc::channel();
let (auth_tx, _auth_rx) = mpsc::channel();
app.job = Some(Job::Build {
name: "foo".into(),
rx,
auth_tx,
units_started: 0,
current: None,
tail: VecDeque::new(),
status_note: None,
warnings: Vec::new(),
started: std::time::Instant::now(),
needs_auth: false,
});
tx.send(BuildMsg::Notice("waiting for the state lock…".into()))
.unwrap();
app.poll_job().unwrap();
let gauge = app.build_progress().expect("job is running");
assert!(
gauge.contains("waiting for the state lock"),
"a notice is the live truth of the moment: {gauge}"
);
tx.send(BuildMsg::Cargo(" Compiling serde v1.0.0".into()))
.unwrap();
app.poll_job().unwrap();
let gauge = app.build_progress().expect("job is running");
assert!(
gauge.contains("1 unit") && !gauge.contains("1 units") && !gauge.contains("waiting"),
"cargo's stream supersedes a stale notice: {gauge}"
);
tx.send(BuildMsg::Warning(
"`foo` is shadowed by /usr/bin/foo".into(),
))
.unwrap();
tx.send(BuildMsg::Done(Ok(()))).unwrap();
app.poll_job().unwrap();
assert!(app.job.is_none(), "the job is finished");
let report = app
.build_report
.as_ref()
.expect("warnings pin a report past a success");
assert!(!report.failed);
assert!(
report.lines.iter().any(|l| l.contains("shadowed")),
"the captured warning reaches the person: {:?}",
report.lines
);
let _ = std::fs::remove_dir_all(&prefix);
}
#[test]
fn the_render_boundary_sanitizes_every_kind() {
let hostile = "warning: `foo` shadowed by /tmp/\u{1b}]0;pwned\u{7}/foo";
for kind in [
crate::LineKind::Cargo,
crate::LineKind::Notice,
crate::LineKind::Warning,
] {
let line = match build_msg(kind, hostile) {
BuildMsg::Cargo(l) | BuildMsg::Notice(l) | BuildMsg::Warning(l) => l,
BuildMsg::NeedAuth | BuildMsg::Done(_) => {
panic!("a line kind maps to a line message")
}
};
assert!(
!line.chars().any(char::is_control),
"no control byte crosses the boundary: {line:?}"
);
assert!(line.contains("shadowed"), "the words do survive");
}
}
#[test]
fn rows_carry_the_cross_prefix_suffix() {
let mut also = std::collections::BTreeMap::new();
also.insert(
"one".to_owned(),
vec![crate::prefixes::AlsoIn {
prefix: std::path::PathBuf::from("/usr/local"),
version: "0.9.0".to_owned(),
}],
);
let m = manifest(&[("one", "1.0.0"), ("two", "2.0.0")]);
let rows = rows_from(&m, None, &also);
let one = rows.iter().find(|r| r.name == "one").unwrap();
assert_eq!(one.also, " [also in /usr/local @0.9.0]");
let two = rows.iter().find(|r| r.name == "two").unwrap();
assert_eq!(two.also, "", "installed nowhere else, no suffix");
}
#[test]
fn elapsed_formats_like_a_clock() {
use std::time::Duration;
assert_eq!(format_elapsed(Duration::from_secs(0)), "00:00");
assert_eq!(format_elapsed(Duration::from_secs(97)), "01:37");
assert_eq!(format_elapsed(Duration::from_secs(59 * 60 + 59)), "59:59");
assert_eq!(format_elapsed(Duration::from_secs(3600)), "1:00:00");
assert_eq!(format_elapsed(Duration::from_secs(3600 + 65)), "1:01:05");
}
}