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);
const CANCEL_GRACE: Duration = Duration::from_secs(2);
#[derive(Clone)]
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: OnConfirm,
}
impl Confirm {
fn new(prompt: &str, action: OnConfirm) -> Self {
Self {
prompt: crate::text::sanitize(prompt),
action,
}
}
}
enum OnConfirm {
Terminal(PendingAction),
Migrate {
name: String,
version: String,
dest: PathBuf,
snap: crate::MigrationSnapshot,
},
MigrateAll {
dest: PathBuf,
plan: Vec<PendingMigrate>,
},
}
#[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(PathBuf),
Done(BuildOutcome),
}
enum BuildOutcome {
Success,
Cancelled,
CompletedWithWarning(String),
Failed(anyhow::Error),
}
enum BuildKind {
Install,
Migrate(Box<MigrateTarget>),
}
struct PendingMigrate {
name: String,
version: String,
dest: PathBuf,
snap: crate::MigrationSnapshot,
}
struct MigrateBatch {
dest: PathBuf,
queue: std::collections::VecDeque<PendingMigrate>,
total: usize,
moved: usize,
warned: Vec<(String, Vec<String>)>,
failed: Vec<(String, Vec<String>)>,
noticed: Vec<(String, Vec<String>)>,
reload_error: Option<String>,
}
enum StartOutcome {
Started,
Refused(String),
}
enum Preflight {
Ready,
NoCache,
Reported(String),
}
struct MigrateTarget {
version: String,
dest: PathBuf,
}
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: Option<PathBuf>,
control: std::sync::Arc<crate::BuildControl>,
kind: BuildKind,
cancel_deadline: Option<std::time::Instant>,
},
}
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,
quit_after_build: bool,
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>,
pending_migrate: Option<PendingMigrate>,
migrate_batch: Option<MigrateBatch>,
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,
pending_migrate: None,
migrate_batch: None,
search_result: None,
build_report: None,
pending_build: None,
ticks: 0,
show_help: false,
pending: None,
job: None,
should_quit: false,
quit_after_build: 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);
self.escalate_overdue_cancel();
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 let Some(req) = self.pending_migrate.take() {
let member = req.name.clone();
if let StartOutcome::Refused(reason) = self.start_migrate(terminal, req)? {
match self.migrate_batch.as_mut() {
Some(batch) => {
batch.failed.push((member, vec![reason]));
self.finalize_migrate_batch(Some("aborted"));
}
None => self.error(&reason),
}
}
continue;
}
if matches!(
&self.job,
Some(Job::Build {
needs_auth: Some(_),
..
})
) {
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 preflight_escalation(terminal: &mut DefaultTerminal, prefix: &Path) -> Result<Preflight> {
let policy = crate::privileged::Policy::for_prefix(prefix);
let escalate = match crate::install_needs_privilege(policy, prefix) {
Ok(escalate) => escalate,
Err(e) => {
return Ok(Preflight::Reported(format!("{e:#}")));
}
} || (matches!(policy.sudo, crate::privileged::Sudo::Allowed)
&& StateLock::preparation_needs_privilege(prefix));
if !escalate {
return Ok(Preflight::Ready);
}
let fresh = match crate::privileged::credentials_fresh() {
Ok(fresh) => fresh,
Err(e) => {
return Ok(Preflight::Reported(format!("{e:#}")));
}
};
let prefix = prefix.to_path_buf();
if !fresh
&& let Err(e) =
Self::suspended(terminal, || crate::privileged::preauthorize(&prefix, true))?
{
return Ok(Preflight::Reported(format!("{e:#}")));
}
match crate::privileged::credentials_fresh() {
Ok(true) => Ok(Preflight::Ready),
Ok(false) => Ok(Preflight::NoCache),
Err(e) => Ok(Preflight::Reported(format!("{e:#}"))),
}
}
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;
match Self::preflight_escalation(terminal, &self.prefix.clone())? {
Preflight::Ready => {}
Preflight::NoCache => {
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(());
}
Preflight::Reported(message) => {
self.error(&message);
return Ok(());
}
}
let (tx, rx) = mpsc::channel();
let (auth_tx, auth_rx) = mpsc::channel();
let control = std::sync::Arc::new(crate::BuildControl::new());
let worker_control = std::sync::Arc::clone(&control);
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 control = worker_control;
let result = crate::tui_install_one(
&prefix,
&spec,
locked,
&mut |k: crate::LineKind, l: &str| {
let _ = line_tx.send(build_msg(k, l));
},
&mut |escalating: &Path| {
if control.cancelled() {
return Err(anyhow::Error::new(crate::BuildCancelled));
}
match crate::privileged::credentials_fresh() {
Ok(true) => Ok(()),
Ok(false) => {
let _ = worker_tx.send(BuildMsg::NeedAuth(escalating.to_path_buf()));
match auth_rx.recv() {
Ok(true) => Ok(()),
Ok(false) if control.cancelled() => {
Err(anyhow::Error::new(crate::BuildCancelled))
}
Ok(false) => anyhow::bail!("sudo authentication failed"),
Err(_) => {
anyhow::bail!("the interface went away mid-authorization")
}
}
}
Err(e) => Err(e),
}
},
&control,
);
let outcome = match result {
Ok(()) => BuildOutcome::Success,
Err(e) if e.downcast_ref::<crate::BuildCancelled>().is_some() => {
BuildOutcome::Cancelled
}
Err(e) => BuildOutcome::Failed(e),
};
let _ = tx.send(BuildMsg::Done(outcome));
});
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: None,
control,
kind: BuildKind::Install,
cancel_deadline: None,
});
Ok(())
}
fn start_migrate(
&mut self,
terminal: &mut DefaultTerminal,
req: PendingMigrate,
) -> Result<StartOutcome> {
let PendingMigrate {
name,
version,
dest,
snap,
} = req;
if self.job.is_some() {
return Ok(StartOutcome::Refused(
"another operation is already running".to_owned(),
));
}
self.build_report = None;
match Self::preflight_escalation(terminal, &dest)? {
Preflight::Ready => {}
Preflight::NoCache => {
return Ok(StartOutcome::Refused(
"sudo does not cache credentials here; migrate via the CLI, \
which prompts interactively"
.to_owned(),
));
}
Preflight::Reported(message) => return Ok(StartOutcome::Refused(message)),
}
let (tx, rx) = mpsc::channel();
let (auth_tx, auth_rx) = mpsc::channel();
let control = std::sync::Arc::new(crate::BuildControl::new());
let worker_control = std::sync::Arc::clone(&control);
let source = self.prefix.clone();
let dest_w = dest.clone();
let worker_name = name.clone();
let worker_tx = tx.clone();
std::thread::spawn(move || {
let line_tx = worker_tx.clone();
let control = worker_control;
let result = crate::tui_migrate_one(
&source,
&dest_w,
&worker_name,
&snap,
&mut |k: crate::LineKind, l: &str| {
let _ = line_tx.send(build_msg(k, l));
},
&mut |escalating: &Path| {
if control.cancelled() {
return Err(anyhow::Error::new(crate::BuildCancelled));
}
match crate::privileged::credentials_fresh() {
Ok(true) => Ok(()),
Ok(false) => {
let _ = worker_tx.send(BuildMsg::NeedAuth(escalating.to_path_buf()));
match auth_rx.recv() {
Ok(true) => Ok(()),
Ok(false) if control.cancelled() => {
Err(anyhow::Error::new(crate::BuildCancelled))
}
Ok(false) => anyhow::bail!("sudo authentication failed"),
Err(_) => {
anyhow::bail!("the interface went away mid-authorization")
}
}
}
Err(e) => Err(e),
}
},
&control,
);
let outcome = match result {
Ok(crate::MigrateOutcome::Moved { .. }) => BuildOutcome::Success,
Ok(crate::MigrateOutcome::Incomplete(reason)) => {
BuildOutcome::CompletedWithWarning(reason)
}
Err(e) if e.downcast_ref::<crate::BuildCancelled>().is_some() => {
BuildOutcome::Cancelled
}
Err(e) => BuildOutcome::Failed(e),
};
let _ = tx.send(BuildMsg::Done(outcome));
});
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: None,
control,
kind: BuildKind::Migrate(Box::new(MigrateTarget { version, dest })),
cancel_deadline: None,
});
Ok(StartOutcome::Started)
}
fn arm_cancel_grace(&mut self) {
if let Some(Job::Build {
cancel_deadline, ..
}) = &mut self.job
{
cancel_deadline.get_or_insert(std::time::Instant::now() + CANCEL_GRACE);
}
}
fn escalate_overdue_cancel(&mut self) {
let due = matches!(
&self.job,
Some(Job::Build {
cancel_deadline: Some(d),
..
}) if std::time::Instant::now() >= *d
);
if !due {
return;
}
let outcome = if let Some(Job::Build {
control,
cancel_deadline,
..
}) = &mut self.job
{
*cancel_deadline = None;
Some(control.request_cancel())
} else {
None
};
if matches!(outcome, Some(crate::CancelOutcome::Killed)) {
self.warn("the build ignored the cancel; SIGKILL sent");
}
}
fn answer_auth(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
if let Some(Job::Build {
control,
auth_tx,
needs_auth,
..
}) = &mut self.job
&& control.cancelled()
{
*needs_auth = None;
let _ = auth_tx.send(false);
return Ok(());
}
let Some(target) = self.job.as_mut().and_then(|job| {
let Job::Build { needs_auth, .. } = job else {
return None;
};
needs_auth.take()
}) else {
return Ok(());
};
let outcome = Self::suspended(terminal, || crate::privileged::preauthorize(&target, 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, .. }) = &mut self.job {
let _ = auth_tx.send(ok);
}
Ok(())
}
fn known_pair_dest(&self) -> Option<PathBuf> {
let others = crate::prefixes::known_others(&self.prefix);
match others.as_slice() {
[dest]
if crate::prefixes::known_others(dest)
.iter()
.any(|p| p == &self.prefix) =>
{
Some(dest.clone())
}
_ => None,
}
}
fn destination_occupied(&mut self, dest: &Path, name: &str) -> bool {
let advisory = StateLock::try_acquire_with(
dest,
&Mode::Shared,
crate::privileged::Policy::for_prefix(dest).screen_owned(),
&mut |_| {},
);
if let Ok(Some(_lock)) = advisory
&& let Ok(manifest) = Manifest::load(dest)
&& let Some(entry) = manifest.crates.get(name)
{
self.error(&format!(
"{name} is already installed at {} ({}); remove one side first \
(no --force by design)",
dest.display(),
entry.version
));
return true;
}
false
}
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_batch_step(
&mut self,
name: &str,
target: &MigrateTarget,
outcome: BuildOutcome,
tail: &VecDeque<String>,
warnings: Vec<String>,
) {
let reload_error = self.reload().err().map(|e| format!("{e:#}"));
let cancelled = matches!(outcome, BuildOutcome::Cancelled);
let (done, total) = {
let Some(batch) = self.migrate_batch.as_mut() else {
return;
};
if let Some(e) = reload_error {
batch.reload_error = Some(e);
}
match outcome {
BuildOutcome::Success => {
batch.moved += 1;
if !warnings.is_empty() {
batch.noticed.push((name.to_owned(), warnings));
}
}
BuildOutcome::Cancelled => {}
BuildOutcome::CompletedWithWarning(reason) => {
let mut lines = vec![crate::text::sanitize(&reason)];
lines.extend(warnings);
batch.warned.push((name.to_owned(), lines));
}
BuildOutcome::Failed(e) => {
batch
.failed
.push((name.to_owned(), Self::failure_lines(&e, tail)));
}
}
(
batch.moved + batch.warned.len() + batch.failed.len(),
batch.total,
)
};
if cancelled {
self.finalize_migrate_batch(Some("cancelled"));
return;
}
self.info(&format!(
"[{done}/{total}] {name} {} processed",
target.version
));
let next = self
.migrate_batch
.as_mut()
.and_then(|batch| batch.queue.pop_front());
match next {
Some(req) => self.pending_migrate = Some(req),
None => self.finalize_migrate_batch(None),
}
}
fn finalize_migrate_batch(&mut self, ended_early: Option<&str>) {
let Some(batch) = self.migrate_batch.take() else {
return;
};
let MigrateBatch {
dest,
queue,
total,
moved,
warned,
failed,
noticed,
reload_error,
} = batch;
let mut summary = format!("migrated {moved} of {total} to {}", dest.display());
if let Some(how) = ended_early {
let unprocessed = queue.len();
let _ = std::fmt::Write::write_fmt(
&mut summary,
format_args!(" ({how}; {unprocessed} not attempted)"),
);
}
if warned.is_empty() && failed.is_empty() && noticed.is_empty() && reload_error.is_none() {
if ended_early.is_some() {
self.warn(&summary);
} else {
self.info(&summary);
}
return;
}
let mut lines = Vec::new();
let section = |lines: &mut Vec<String>, header: &str, entries: &[(String, Vec<String>)]| {
if entries.is_empty() {
return;
}
if !lines.is_empty() {
lines.push(String::new());
}
lines.push(header.to_owned());
for (name, entry_lines) in entries {
lines.push(crate::text::sanitize(&format!(" {name}:")));
for line in entry_lines {
lines.push(crate::text::sanitize(&format!(" {line}")));
}
}
};
section(&mut lines, "failed:", &failed);
section(
&mut lines,
"destination committed, source not retired:",
&warned,
);
section(&mut lines, "migrated, with build warnings:", ¬iced);
if let Some(e) = reload_error {
if !lines.is_empty() {
lines.push(String::new());
}
lines.push(crate::text::sanitize(&format!(
"(and a mid-batch list reload failed: {e})"
)));
}
self.build_report = Some(BuildReport {
title: format!("migrate --all: {moved} of {total} migrated"),
lines,
failed: !failed.is_empty(),
});
self.warn(&format!(
"{summary} — details in the panel; Esc/Enter dismisses"
));
}
fn failure_lines(e: &anyhow::Error, tail: &VecDeque<String>) -> Vec<String> {
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());
}
lines
}
fn finish_build(
&mut self,
name: &str,
kind: &BuildKind,
outcome: BuildOutcome,
tail: &VecDeque<String>,
warnings: Vec<String>,
) {
let verb = match kind {
BuildKind::Install => "install",
BuildKind::Migrate(_) => "migrate",
};
if self.migrate_batch.is_some()
&& let BuildKind::Migrate(target) = kind
{
self.finish_batch_step(name, target, outcome, tail, warnings);
return;
}
if matches!(outcome, BuildOutcome::Cancelled) {
self.info(&format!("{verb} {name} cancelled"));
return;
}
let reload = self.reload();
match outcome {
BuildOutcome::Cancelled => unreachable!("returned above"),
BuildOutcome::Success => {
let note = match kind {
BuildKind::Install => tail
.iter()
.rev()
.find(|l| l.starts_with("installed "))
.cloned()
.unwrap_or_else(|| format!("install {name} finished")),
BuildKind::Migrate(target) => format!(
"migrated {name} {} to {}",
target.version,
target.dest.display()
),
};
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!("{verb} {name}: warnings"),
lines,
failed: false,
});
self.warn(&format!(
"{note} — with warnings in the panel; Esc/Enter dismisses"
));
}
}
BuildOutcome::CompletedWithWarning(reason) => {
let mut lines: Vec<String> = vec![crate::text::sanitize(&reason)];
if !warnings.is_empty() {
lines.push(String::new());
lines.extend(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!("{verb} {name}: completed with a warning"),
lines,
failed: false,
});
self.warn(&format!(
"{verb} {name} finished with a warning — details in the panel; \
Esc/Enter dismisses"
));
}
BuildOutcome::Failed(e) => {
let mut lines = Self::failure_lines(&e, tail);
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') {
if let Some(Job::Build { control, .. }) = &self.job {
match control.request_cancel() {
crate::CancelOutcome::Accepted => {
self.arm_cancel_grace();
self.info(
"cancelling; quitting when the build stops (Ctrl-C again: SIGKILL)",
);
}
crate::CancelOutcome::Killed => {
self.warn("SIGKILL sent; quitting when the build stops");
}
crate::CancelOutcome::AlreadyStopping => {
self.info("the build is already stopping; quitting when it does");
}
crate::CancelOutcome::TooLate => {
self.info("placement in progress; quitting when it finishes");
}
}
self.quit_after_build = true;
} else {
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; c cancels it, Ctrl-C cancels and quits");
} 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; c cancels it, Ctrl-C cancels and quits");
} else {
self.should_quit = true;
}
}
KeyCode::Char('c') => {
if let Some(Job::Build { name, control, .. }) = &self.job {
let name = name.clone();
match control.request_cancel() {
crate::CancelOutcome::Accepted => {
self.arm_cancel_grace();
self.info(&format!("cancelling {name}… (c again sends SIGKILL)"));
}
crate::CancelOutcome::Killed => {
self.warn(&format!("SIGKILL sent to the {name} build"));
}
crate::CancelOutcome::AlreadyStopping => {
self.info("the build is already stopping");
}
crate::CancelOutcome::TooLate => {
self.info("placement already started; too late to cancel");
}
}
}
}
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::new(
&prompt,
OnConfirm::Terminal(PendingAction::Remove(row.name.clone())),
));
}
}
KeyCode::Char('m') => self.migrate_selected(),
KeyCode::Char('M') => self.migrate_everything(),
_ => {}
}
}
fn migrate_selected(&mut self) {
if let Some(row) = self.selected_row().cloned() {
match self.known_pair_dest() {
Some(dest) => {
if self.destination_occupied(&dest, &row.name) {
return;
}
let snap = match crate::MigrationSnapshot::from_parts(
&row.name,
&row.version,
row.bins.clone(),
row.locked,
row.pinned,
) {
Ok(snap) => snap,
Err(e) => {
self.error(&format!("{e:#}"));
return;
}
};
let prompt = format!(
"migrate {} {}: {} -> {}? the exact version is rebuilt \
there, then retired here [y/N]",
row.name,
row.version,
self.prefix.display(),
dest.display()
);
self.confirm = Some(Confirm::new(
&prompt,
OnConfirm::Migrate {
name: row.name.clone(),
version: row.version.clone(),
dest,
snap,
},
));
}
None => self.info(
"TUI migrate covers the /usr/local <-> ~/.local pair; \
migrate elsewhere via the CLI: cargo lbin migrate NAME --to PREFIX",
),
}
}
}
fn migrate_everything(&mut self) {
let Some(dest) = self.known_pair_dest() else {
self.info(
"TUI migrate covers the /usr/local <-> ~/.local pair; \
migrate elsewhere via the CLI: cargo lbin migrate --all --to PREFIX",
);
return;
};
if let Err(e) = self.reload() {
self.error(&format!("cannot plan the batch: {e:#}"));
return;
}
if self.rows.is_empty() {
self.info("nothing to migrate");
return;
}
let mut plan = Vec::with_capacity(self.rows.len());
for row in &self.rows {
match crate::MigrationSnapshot::from_parts(
&row.name,
&row.version,
row.bins.clone(),
row.locked,
row.pinned,
) {
Ok(snap) => plan.push(PendingMigrate {
name: row.name.clone(),
version: row.version.clone(),
dest: dest.clone(),
snap,
}),
Err(e) => {
self.error(&format!("{e:#}"));
return;
}
}
}
let prompt = format!(
"migrate all {} crate(s): {} -> {}? exact versions are rebuilt \
there, then retired here; c cancels the batch [y/N]",
plan.len(),
self.prefix.display(),
dest.display()
);
self.confirm = Some(Confirm::new(&prompt, OnConfirm::MigrateAll { dest, plan }));
}
fn on_key_confirm(&mut self, key: KeyEvent) {
let Some(confirm) = self.confirm.take() else {
return;
};
if matches!(key.code, KeyCode::Char('y' | 'Y')) {
match confirm.action {
OnConfirm::Terminal(action) => self.queue(action),
OnConfirm::Migrate {
name,
version,
dest,
snap,
} => {
self.pending_migrate = Some(PendingMigrate {
name,
version,
dest,
snap,
});
}
OnConfirm::MigrateAll { dest, plan } => {
let mut queue: std::collections::VecDeque<PendingMigrate> =
plan.into_iter().collect();
let total = queue.len();
let Some(first) = queue.pop_front() else {
return;
};
self.migrate_batch = Some(MigrateBatch {
dest,
queue,
total,
moved: 0,
warned: Vec::new(),
failed: Vec::new(),
noticed: Vec::new(),
reload_error: None,
});
self.pending_migrate = Some(first);
}
}
} 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,
control,
kind,
cancel_deadline,
} => {
let mut done: Option<BuildOutcome> = 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(target)) => needs_auth = Some(target),
Ok(BuildMsg::Done(outcome)) => {
done = Some(outcome);
break;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
bail!("build worker aborted; the terminal was reset by the panic")
}
}
}
match done {
Some(outcome) => {
self.finish_build(&name, &kind, outcome, &tail, warnings);
if self.quit_after_build {
self.should_quit = true;
}
}
None => {
self.job = Some(Job::Build {
name,
rx,
auth_tx,
units_started,
current,
tail,
status_note,
warnings,
started,
needs_auth,
control,
kind,
cancel_deadline,
});
}
}
}
}
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 the_grace_timer_is_one_shot_and_fires_only_past_the_deadline() {
let prefix = std::env::temp_dir().join("cargo-lbin-test-tui-grace");
let _ = std::fs::remove_dir_all(&prefix);
std::fs::create_dir_all(&prefix).unwrap();
let mut app = App::new(&prefix).unwrap();
let control = std::sync::Arc::new(crate::BuildControl::new());
assert!(matches!(
control.request_cancel(),
crate::CancelOutcome::Accepted
));
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: None,
control: std::sync::Arc::clone(&control),
kind: BuildKind::Install,
cancel_deadline: Some(std::time::Instant::now() + Duration::from_secs(60)),
});
app.escalate_overdue_cancel();
assert!(matches!(
&app.job,
Some(Job::Build {
cancel_deadline: Some(_),
..
})
));
if let Some(Job::Build {
cancel_deadline, ..
}) = &mut app.job
{
*cancel_deadline = std::time::Instant::now().checked_sub(Duration::from_millis(1));
assert!(
cancel_deadline.is_some(),
"the clock is past its first millisecond"
);
}
app.escalate_overdue_cancel();
assert!(matches!(
&app.job,
Some(Job::Build {
cancel_deadline: None,
..
})
));
app.escalate_overdue_cancel();
let _ = std::fs::remove_dir_all(&prefix);
}
#[test]
fn a_batch_tallies_advances_and_a_cancel_ends_it() {
let prefix = std::env::temp_dir().join("cargo-lbin-test-tui-batch");
let _ = std::fs::remove_dir_all(&prefix);
std::fs::create_dir_all(&prefix).unwrap();
let mut app = App::new(&prefix).unwrap();
let dest = prefix.join("other");
let pending = |name: &str| PendingMigrate {
name: name.into(),
version: "0.1.0".into(),
dest: dest.clone(),
snap: crate::MigrationSnapshot::from_parts(
name,
"0.1.0",
vec![name.into()],
false,
false,
)
.unwrap(),
};
let target = MigrateTarget {
version: "0.1.0".into(),
dest: dest.clone(),
};
app.migrate_batch = Some(MigrateBatch {
dest: dest.clone(),
queue: [pending("bar")].into_iter().collect(),
total: 2,
moved: 0,
warned: Vec::new(),
failed: Vec::new(),
noticed: Vec::new(),
reload_error: None,
});
let no_tail = VecDeque::new();
app.finish_batch_step(
"foo",
&target,
BuildOutcome::Success,
&no_tail,
vec!["foo shadows something".to_owned()],
);
assert!(app.pending_migrate.is_some(), "the queue advanced");
let batch = app.migrate_batch.as_ref().unwrap();
assert_eq!(batch.moved, 1);
assert_eq!(batch.noticed.len(), 1, "the shadow warning survived");
app.pending_migrate = None;
app.finish_batch_step(
"bar",
&target,
BuildOutcome::Failed(anyhow::anyhow!("already installed, say")),
&no_tail,
Vec::new(),
);
assert!(app.migrate_batch.is_none(), "the batch wrapped up");
let report = app.build_report.take().expect("a shortfall gets a panel");
assert!(report.failed);
assert!(report.title.contains("1 of 2"));
assert!(
report.lines.iter().any(|l| l.contains("shadows something")),
"the successful member's warning reached the summary panel"
);
app.migrate_batch = Some(MigrateBatch {
dest: dest.clone(),
queue: [pending("baz"), pending("qux")].into_iter().collect(),
total: 3,
moved: 1,
warned: Vec::new(),
failed: Vec::new(),
noticed: Vec::new(),
reload_error: None,
});
app.finish_batch_step(
"bar",
&target,
BuildOutcome::Cancelled,
&no_tail,
Vec::new(),
);
assert!(app.migrate_batch.is_none(), "a cancel ends the whole batch");
assert!(
app.pending_migrate.is_none(),
"nothing was silently continued"
);
let _ = std::fs::remove_dir_all(&prefix);
}
#[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: None,
control: std::sync::Arc::new(crate::BuildControl::new()),
kind: BuildKind::Install,
cancel_deadline: None,
});
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(BuildOutcome::Success)).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");
}
}