mod ui;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, 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::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 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,
}
impl Filter {
pub const ALL: [Filter; 2] = [Filter::All, Filter::Updates];
pub fn index(self) -> usize {
match self {
Filter::All => 0,
Filter::Updates => 1,
}
}
fn next(self) -> Self {
match self {
Filter::All => Filter::Updates,
Filter::Updates => Filter::All,
}
}
}
#[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),
}
enum Job {
Check(Receiver<Result<Vec<Checked>>>),
Search {
query: String,
rx: Receiver<Result<Vec<api::Hit>>>,
},
}
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}`…"),
}
}
}
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 show_help: bool,
pending: Option<PendingAction>,
job: Option<Job>,
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,
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)?
};
self.rows = rows_from(&manifest, report);
self.report_age = report.map(Report::age);
self.clamp_selection();
Ok(())
}
pub fn visible(&self) -> Vec<&Row> {
self.rows
.iter()
.filter(|r| match self.filter {
Filter::All => true,
Filter::Updates => matches!(r.status, RowStatus::Outdated(_)),
})
.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(_)))
.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 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 {
terminal.draw(|frame| ui::draw(frame, self))?;
if let Some(action) = self.pending.take() {
self.run_in_terminal(terminal, &action)?;
continue;
}
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 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.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') => self.should_quit = true,
KeyCode::Esc => {
if self.search_result.take().is_none() {
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 | KeyCode::BackTab => {
self.filter = self.filter.next();
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)) => 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() && purpose == InputPurpose::Search {
self.error("busy; wait for the current lookup to finish");
return;
}
self.input = Some(Input {
purpose,
buffer: String::new(),
});
}
fn queue(&mut self, action: PendingAction) {
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")
}
},
}
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();
match persisted {
Ok(()) => self.info(&format!("checked: {n} update(s) available")),
Err(e) => self.warn(&format!(
"checked: {n} update(s) available; 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: text.to_owned(),
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 rows_from(manifest: &Manifest, report: Option<&Report>) -> 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,
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));
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);
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::All);
for (i, f) in Filter::ALL.iter().enumerate() {
assert_eq!(f.index(), i);
}
}
}