diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 40b78f3..c0f8674 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -37,7 +37,7 @@ concurrency: jobs: tools: - name: fetch + games + name: fetch + games + lazybox runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/Cargo.toml b/Cargo.toml index 70d2eb2..211cf8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,10 @@ path = "src/bin/fetch.rs" name = "games" path = "src/bin/games.rs" +[[bin]] +name = "lazybox" +path = "src/bin/lazybox.rs" + [dependencies] crossterm = { version = "=0.29.0", default-features = false, features = ["events", "windows"] } diff --git a/README.md b/README.md index 85ff000..3b5bb35 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,16 @@ Small native terminal tools with one job each: - `fetch` shows a fast system and CPU overview with styled, plain, or JSON output. -- `games` is an original native terminal arcade with eleven complete game modes. +- `games` is a native clean-room terminal arcade with eleven complete game modes. +- `lazybox` is one direct dashboard for Docker containers, Apple containers, and Slurm jobs. -The binaries never download or install software. `games` uses Crossterm for -raw keyboard input and terminal restoration; no upstream game code, assets, or -runtime is bundled. +The binaries never download or install software. `games` and `lazybox` use +Crossterm for raw keyboard input and terminal restoration; no upstream TUI, +game code, or assets are bundled. ## Install -After the first release is published, install both commands from its native +After the first release is published, install all commands from its native PyPI wheel: ```sh @@ -30,6 +31,7 @@ Rust 1.97.1 is pinned for builds. cargo build --release --locked ./target/release/fetch ./target/release/games +./target/release/lazybox ``` Useful non-interactive forms: @@ -40,6 +42,8 @@ fetch --json games list --json games info merge games run merge --seed 1 +lazybox snapshot --backend auto --json +lazybox open --backend docker ``` `NO_COLOR=1` disables decoration. Interactive play needs a real terminal and @@ -54,6 +58,16 @@ informed only by the high-level behavior of the research projects. Sessions are finite and seeded; v1 has no network, audio, persistence, plugins, external executables, or copied assets. +## Unified operations + +`lazybox` auto-detects a responding Docker, Apple Container, or Slurm backend, +then renders its own consistent dashboard. It calls only the installed native +CLI for inventory, details, bounded container logs, and confirmed start, stop, +restart, or cancellation actions. It never invokes Lazydocker, Lazyslurm, or +Lazycontainer. Plain and JSON snapshots make the same inventory usable in +scripts without entering raw terminal mode. Backend calls stop after 30 seconds +and capture at most 8 MiB of combined output. + ## Scope `fetch` has detailed native collection on macOS and Linux and a smaller @@ -61,7 +75,9 @@ environment-backed view on Windows. It is intentionally not a configurable replacement for every Fastfetch module. See [research](docs/research.md) and [fetch benchmarks](benches/fetch.md) for the measured comparison and limits. The [games benchmark](benches/games.md) measures the native arcade's owned -non-interactive path and binary size. +non-interactive path and binary size. The [lazybox benchmark](benches/lazybox.md) +measures native CLI startup and package size without pretending backend latency +belongs to the dashboard. ## Project diff --git a/benches/games.md b/benches/games.md index 7bff9ae..6a09373 100644 --- a/benches/games.md +++ b/benches/games.md @@ -16,11 +16,6 @@ Measured 2026-08-14 on Darwin 25.5.0 ARM64 with Rust 1.97.1 and Hyperfine The two list commands are not equivalent workloads, so their timing difference is not a performance claim. The native binary adds 84,496 bytes (about 24%) for eleven game engines and cross-platform terminal input. -The macOS ARM64 wheel is 410,899 bytes, including both binaries, package -metadata, project and dependency licenses, and CycloneDX SBOM; that is 67,191 -bytes (about 20%) over -the prior 343,708-byte wheel. - Run the same benchmark used by CI: ```sh diff --git a/benches/lazybox.md b/benches/lazybox.md new file mode 100644 index 0000000..310095b --- /dev/null +++ b/benches/lazybox.md @@ -0,0 +1,26 @@ +# Lazybox benchmark + +`lazybox --version` measures native process startup without requiring Docker, +Apple Container, or Slurm. Backend snapshot latency belongs mostly to the +selected external CLI and environment, so it is not used to claim that +Lazybox is faster than the three unrelated upstream applications. + +Measured 2026-08-17 on Darwin 25.5.0 ARM64 with Rust 1.97.1 and Hyperfine +1.20.0, using 100 runs after 20 warmups: + +| Command | Mean ± standard deviation | Stripped binary | +| --- | ---: | ---: | +| `lazybox --version` | 1.5 ± 0.1 ms | 422,384 bytes | + +The macOS ARM64 wheel is 625,260 bytes for all three binaries, package +metadata, project and dependency licenses, and the CycloneDX SBOM. + +Run 100 measured launches after 20 warmups and record all three packaged tools: + +```sh +cargo build --release --locked +RUNS=100 WARMUP=20 bash benches/run.sh +``` + +Results and stripped binary sizes are written below `target/benchmarks/`. +Use one host and backend fixture for any future end-to-end comparison. diff --git a/benches/run.sh b/benches/run.sh index 70f876f..a29a97c 100755 --- a/benches/run.sh +++ b/benches/run.sh @@ -7,12 +7,13 @@ warmup=${WARMUP:-20} output="$project/target/benchmarks" fetch="$project/target/release/fetch" games="$project/target/release/games" +lazybox="$project/target/release/lazybox" command -v hyperfine >/dev/null 2>&1 || { echo "benchmarks: install hyperfine 1.20.0" >&2 exit 127 } -for binary in "$fetch" "$games"; do +for binary in "$fetch" "$games" "$lazybox"; do [[ -x "$binary" ]] || { echo "benchmarks: run cargo build --release --locked first" >&2 exit 2 @@ -25,10 +26,12 @@ hyperfine --shell=none --warmup "$warmup" --runs "$runs" \ --export-json "$output/tools.json" \ --export-markdown "$output/tools.md" \ --command-name fetch "$fetch --plain" \ - --command-name games "$games list --plain" + --command-name games "$games list --plain" \ + --command-name lazybox "$lazybox --version" fetch_size=$(wc -c < "$fetch" | tr -d ' ') games_size=$(wc -c < "$games" | tr -d ' ') +lazybox_size=$(wc -c < "$lazybox" | tr -d ' ') { echo "## Keys Tools benchmarks" echo @@ -42,6 +45,7 @@ games_size=$(wc -c < "$games" | tr -d ' ') echo "| --- | ---: |" echo "| fetch | $fetch_size |" echo "| games | $games_size |" + echo "| lazybox | $lazybox_size |" echo echo "Hyperfine $runs runs after $warmup warmups; hosted-runner results are informational." } > "$output/summary.md" diff --git a/docs/research.md b/docs/research.md index 39e51ef..dd03e4d 100644 --- a/docs/research.md +++ b/docs/research.md @@ -55,6 +55,11 @@ or assets are copied into Keys Tools. high-level behavior. It uses an original implementation, maps, text, words, and ASCII assets; no upstream source, data, branding, save format, or executable is copied, linked, or launched. +- `lazybox` owns a small dashboard and directly calls the installed `docker`, + Apple `container`, or Slurm commands. It does not launch or embed Lazydocker, + Lazycontainer, or Lazyslurm. One row model replaces their unrelated UI and + dependency stacks; backend-specific operations remain direct `match` arms, + not a plugin or trait layer. - Crossterm is the only runtime dependency and owns cross-platform raw input, resize events, alternate-screen entry, and terminal restoration. Ratatui is excluded because these fixed layouts do not need a widget or layout engine. @@ -62,4 +67,4 @@ or assets are copied into Keys Tools. which put native binaries on the environment `PATH`. PyO3 is excluded because the commands expose no Python API; native wheels are required per OS/architecture. [`uv tool install`](https://docs.astral.sh/uv/concepts/tools/) - then exposes both commands from an isolated tool environment. + then exposes all commands from an isolated tool environment. diff --git a/pyproject.toml b/pyproject.toml index 347f816..2565354 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.md" requires-python = ">=3.9" license = "MIT" authors = [{ name = "Keys" }] -keywords = ["cli", "terminal", "system-information", "games"] +keywords = ["cli", "terminal", "system-information", "containers", "slurm", "games"] classifiers = [ "Environment :: Console", "Programming Language :: Rust", diff --git a/scripts/smoke-wheel.sh b/scripts/smoke-wheel.sh index 52b1ac2..9f235c0 100644 --- a/scripts/smoke-wheel.sh +++ b/scripts/smoke-wheel.sh @@ -36,3 +36,4 @@ fi "$python_bin" -m pip install --disable-pip-version-check --no-cache-dir --no-deps "$1" "$bin_dir/fetch$suffix" --json "$bin_dir/games$suffix" list --json +"$bin_dir/lazybox$suffix" --version diff --git a/src/bin/lazybox.rs b/src/bin/lazybox.rs new file mode 100644 index 0000000..bd2d731 --- /dev/null +++ b/src/bin/lazybox.rs @@ -0,0 +1,9 @@ +fn main() { + match keys_tools::lazybox::run(std::env::args().skip(1).collect()) { + Ok(code) => std::process::exit(code), + Err(error) => { + eprintln!("lazybox: {error}"); + std::process::exit(2); + } + } +} diff --git a/src/games.rs b/src/games.rs index c947c73..242a34e 100644 --- a/src/games.rs +++ b/src/games.rs @@ -5,16 +5,13 @@ mod turns; use std::io::{self, IsTerminal as _, Write}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use crossterm::cursor::{Hide, MoveTo, Show}; +use crossterm::cursor::MoveTo; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::queue; use crossterm::style::{Color, Print, ResetColor, SetForegroundColor}; -use crossterm::terminal::{ - BeginSynchronizedUpdate, Clear, ClearType, DisableLineWrap, EnableLineWrap, - EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, - enable_raw_mode, size, -}; -use crossterm::{execute, queue}; +use crossterm::terminal::{BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate, size}; +use crate::terminal::Session; use crate::{VERSION, json_string, use_color}; const HELP: &str = "Native clean-room terminal arcade\n\nUsage:\n games\n games list [--plain | --json]\n games info NAME\n games run NAME [--seed NUMBER]\n\nOptions:\n --plain Stable text without decoration\n --json Machine-readable game list\n --seed NUMBER Reproducible randomized setup\n -h, --help Show this help\n -V, --version Show the version\n\nInteractive controls: arrows or WASD; Esc returns to the menu. Most modes use r to restart and q to quit; text modes use Ctrl-R and Ctrl-C."; @@ -284,7 +281,7 @@ fn session_seed() -> u64 { } fn interactive(initial: Option<&str>, mut seed: u64) -> Result { - let _terminal = TerminalSession::enter().map_err(|error| format!("terminal: {error}"))?; + let _terminal = Session::enter().map_err(|error| format!("terminal: {error}"))?; let mut stdout = io::stdout(); let mut selected = initial .and_then(|name| MODES.iter().position(|mode| mode.name == name)) @@ -519,37 +516,6 @@ fn write_centered( Ok(()) } -struct TerminalSession; - -impl TerminalSession { - fn enter() -> io::Result { - enable_raw_mode()?; - if let Err(error) = execute!(io::stdout(), EnterAlternateScreen, Hide, DisableLineWrap) { - restore_terminal(); - return Err(error); - } - Ok(Self) - } -} - -impl Drop for TerminalSession { - fn drop(&mut self) { - restore_terminal(); - } -} - -fn restore_terminal() { - let _ = execute!( - io::stdout(), - EndSynchronizedUpdate, - ResetColor, - Show, - EnableLineWrap, - LeaveAlternateScreen - ); - let _ = disable_raw_mode(); -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/lazybox.rs b/src/lazybox.rs new file mode 100644 index 0000000..564e2f9 --- /dev/null +++ b/src/lazybox.rs @@ -0,0 +1,830 @@ +use std::io::{self, IsTerminal as _, Read, Write}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use crossterm::cursor::MoveTo; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::queue; +use crossterm::style::{Color, Print, ResetColor, SetForegroundColor}; +use crossterm::terminal::{BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate, size}; + +use crate::terminal::Session; +use crate::{VERSION, json_string, use_color}; + +const HELP: &str = "One native dashboard for Docker, Apple containers, and Slurm\n\nUsage:\n lazybox [open] [--backend auto|docker|apple|slurm]\n lazybox snapshot [--backend auto|docker|apple|slurm] [--plain|--json]\n\nOptions:\n --backend NAME Select or auto-detect one live backend\n --plain Stable tab-separated snapshot\n --json Machine-readable snapshot\n -h, --help Show this help\n -V, --version Show the version\n\nDashboard controls: Up/Down select, Tab backend, Enter details, l logs, s start/stop, x restart Docker, c cancel Slurm, r refresh, q quit."; +const MAX_OUTPUT: usize = 8 * 1024 * 1024; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Backend { + Docker, + Apple, + Slurm, +} + +impl Backend { + const ALL: [Self; 3] = [Self::Docker, Self::Apple, Self::Slurm]; + + fn name(self) -> &'static str { + match self { + Self::Docker => "docker", + Self::Apple => "apple", + Self::Slurm => "slurm", + } + } + + fn parse(value: &str) -> Result, String> { + match value { + "auto" => Ok(None), + "docker" => Ok(Some(Self::Docker)), + "apple" => Ok(Some(Self::Apple)), + "slurm" => Ok(Some(Self::Slurm)), + _ => Err(format!( + "unknown backend {value:?}; use auto, docker, apple, or slurm" + )), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Mode { + Open, + Snapshot, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Format { + Plain, + Json, +} + +#[derive(Debug, PartialEq, Eq)] +struct Options { + mode: Option, + backend: Option, + format: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Row { + id: String, + name: String, + source: String, + state: String, + detail: String, +} + +struct Snapshot { + backend: Backend, + rows: Vec, +} + +#[derive(Clone, Copy)] +enum Action { + Toggle, + Restart, + Cancel, +} + +pub fn run(arguments: Vec) -> Result { + match arguments.as_slice() { + [argument] if matches!(argument.as_str(), "-h" | "--help") => { + println!("{HELP}"); + return Ok(0); + } + [argument] if matches!(argument.as_str(), "-V" | "--version") => { + println!("lazybox {VERSION}"); + return Ok(0); + } + _ => {} + } + let options = parse_options(arguments)?; + let mode = options.mode.unwrap_or_else(|| { + if options.format.is_none() && io::stdin().is_terminal() && io::stdout().is_terminal() { + Mode::Open + } else { + Mode::Snapshot + } + }); + if mode == Mode::Open && options.format.is_some() { + return Err("--plain and --json are snapshot options".into()); + } + if mode == Mode::Open && (!io::stdin().is_terminal() || !io::stdout().is_terminal()) { + return Err("the dashboard needs a terminal; use 'lazybox snapshot'".into()); + } + + let snapshot = options.backend.map_or_else(auto_load, load)?; + match mode { + Mode::Open => interactive(snapshot), + Mode::Snapshot => { + if options.format == Some(Format::Json) { + print_json(&snapshot); + } else { + print_plain(&snapshot); + } + Ok(0) + } + } +} + +fn parse_options(arguments: Vec) -> Result { + let mut options = Options { + mode: None, + backend: None, + format: None, + }; + let mut arguments = arguments.into_iter(); + while let Some(argument) = arguments.next() { + match argument.as_str() { + "open" if options.mode.is_none() => options.mode = Some(Mode::Open), + "snapshot" if options.mode.is_none() => options.mode = Some(Mode::Snapshot), + "--backend" => { + let value = arguments + .next() + .ok_or_else(|| "--backend needs a value".to_owned())?; + options.backend = Backend::parse(&value)?; + } + "--plain" if options.format.is_none() => options.format = Some(Format::Plain), + "--json" if options.format.is_none() => options.format = Some(Format::Json), + "--plain" | "--json" => { + return Err("--plain and --json cannot be used together".into()); + } + _ => { + return Err(format!( + "unknown argument {argument:?}; try 'lazybox --help'" + )); + } + } + } + Ok(options) +} + +fn auto_load() -> Result { + let mut errors = Vec::new(); + for backend in Backend::ALL { + match load(backend) { + Ok(snapshot) => return Ok(snapshot), + Err(error) => errors.push(format!("{}: {error}", backend.name())), + } + } + Err(format!("no backend responded ({})", errors.join("; "))) +} + +fn load(backend: Backend) -> Result { + let output = match backend { + Backend::Docker => command_output( + "docker", + &[ + "container", + "ls", + "--all", + "--no-trunc", + "--format", + "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}", + ], + false, + )?, + Backend::Apple => { + command_output("container", &["list", "--all", "--format", "table"], false)? + } + Backend::Slurm => command_output( + "squeue", + &["--noheader", "--format=%i|%j|%u|%t|%M|%N|%P"], + false, + )?, + }; + let rows = match backend { + Backend::Docker => parse_docker(&output), + Backend::Apple => parse_apple(&output), + Backend::Slurm => parse_slurm(&output), + }; + Ok(Snapshot { backend, rows }) +} + +fn command_output( + program: &str, + arguments: &[&str], + include_stderr: bool, +) -> Result { + let mut child = Command::new(program) + .args(arguments) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| match error.kind() { + io::ErrorKind::NotFound => format!("{program} is not installed"), + _ => format!("cannot run {program}: {error}"), + })?; + let (Some(stdout), Some(stderr)) = (child.stdout.take(), child.stderr.take()) else { + let _ = child.kill(); + return Err(format!("cannot capture {program} output")); + }; + let stdout = thread::spawn(move || read_limited(stdout, MAX_OUTPUT)); + let stderr = thread::spawn(move || read_limited(stderr, MAX_OUTPUT)); + let started = Instant::now(); + let mut timed_out = false; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Ok(status), + Ok(None) => {} + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + break Err(format!("cannot wait for {program}: {error}")); + } + } + if started.elapsed() >= COMMAND_TIMEOUT { + timed_out = true; + let _ = child.kill(); + break child + .wait() + .map_err(|error| format!("cannot stop {program}: {error}")); + } + thread::sleep(Duration::from_millis(10)); + }; + let stdout = stdout + .join() + .map_err(|_| format!("cannot read {program} stdout"))? + .map_err(|error| format!("cannot read {program} stdout: {error}"))?; + let stderr = stderr + .join() + .map_err(|_| format!("cannot read {program} stderr"))? + .map_err(|error| format!("cannot read {program} stderr: {error}"))?; + if timed_out { + status?; + return Err(format!("{program} timed out after 30 seconds")); + } + let status = status?; + if stdout.len().saturating_add(stderr.len()) > MAX_OUTPUT { + return Err(format!("{program} output exceeds 8 MiB")); + } + if !status.success() { + let error = clean(&String::from_utf8_lossy(&stderr)); + return Err(if error.is_empty() { + format!("{program} exited with {status}") + } else { + format!("{program}: {error}") + }); + } + Ok(success_text(&stdout, &stderr, include_stderr)) +} + +fn read_limited(reader: impl Read, limit: usize) -> io::Result> { + let mut output = Vec::new(); + reader + .take(limit.saturating_add(1) as u64) + .read_to_end(&mut output)?; + Ok(output) +} + +fn success_text(stdout: &[u8], stderr: &[u8], include_stderr: bool) -> String { + let mut output = String::from_utf8_lossy(stdout).into_owned(); + if include_stderr && !stderr.is_empty() { + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + output.push_str(&String::from_utf8_lossy(stderr)); + } + output +} + +fn parse_docker(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let mut fields = line.split('\t'); + let row = Row { + id: clean(fields.next()?), + name: clean(fields.next()?), + source: clean(fields.next()?), + state: clean(fields.next()?), + detail: clean(fields.next()?), + }; + fields.next().is_none().then_some(row) + }) + .collect() +} + +// ponytail: parse Apple's small stable table without a JSON dependency; switch to +// `--format json` only if Apple changes the columns. +fn parse_apple(output: &str) -> Vec { + output + .lines() + .skip_while(|line| line.trim().is_empty()) + .skip(1) + .filter_map(|line| { + let mut fields = line.split_whitespace(); + let id = fields.next()?; + let source = fields.next()?; + fields.next()?; + fields.next()?; + let state = fields.next()?; + Some(Row { + id: clean(id), + name: clean(id), + source: clean(source), + state: clean(state), + detail: clean(&fields.collect::>().join(" ")), + }) + }) + .collect() +} + +fn parse_slurm(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let mut fields = line.split('|'); + let id = fields.next()?; + let name = fields.next()?; + let user = fields.next()?; + let state = fields.next()?; + let time = fields.next()?; + let node = fields.next()?; + let source = fields.next()?; + let row = Row { + id: clean(id), + name: clean(name), + source: clean(source), + state: clean(state), + detail: clean(&format!("{user} {time} {node}")), + }; + fields.next().is_none().then_some(row) + }) + .collect() +} + +fn clean(value: &str) -> String { + value + .chars() + .filter(|character| !character.is_control()) + .take(512) + .collect::() + .trim() + .to_owned() +} + +fn safe_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 256 + && !value.starts_with('-') + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b'_' | b'-' | b'.' | b'[' | b']' | b'%' | b'+') + }) +} + +fn print_plain(snapshot: &Snapshot) { + println!("Backend\t{}", snapshot.backend.name()); + println!("ID\tName\tSource\tState\tDetail"); + for row in &snapshot.rows { + println!( + "{}\t{}\t{}\t{}\t{}", + row.id, row.name, row.source, row.state, row.detail + ); + } +} + +fn print_json(snapshot: &Snapshot) { + let rows = snapshot + .rows + .iter() + .map(|row| { + format!( + "{{\"id\":{},\"name\":{},\"source\":{},\"state\":{},\"detail\":{}}}", + json_string(&row.id), + json_string(&row.name), + json_string(&row.source), + json_string(&row.state), + json_string(&row.detail) + ) + }) + .collect::>() + .join(","); + println!( + "{{\"backend\":{},\"items\":[{rows}]}}", + json_string(snapshot.backend.name()) + ); +} + +fn interactive(mut snapshot: Snapshot) -> Result { + let _terminal = Session::enter().map_err(|error| format!("terminal: {error}"))?; + let mut stdout = io::stdout(); + let mut selected = 0_usize; + let mut details = Vec::new(); + let mut status = format!("{} items", snapshot.rows.len()); + let mut pending = None; + loop { + draw( + &mut stdout, + &snapshot, + selected, + &details, + &status, + pending, + size().unwrap_or((80, 24)), + ) + .map_err(|error| format!("draw: {error}"))?; + + match event::read().map_err(|error| format!("input: {error}"))? { + Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { + if key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('c' | 'C')) + || matches!(key.code, KeyCode::Char('q' | 'Q')) + { + break; + } + if let Some(action) = pending { + match key.code { + KeyCode::Char('y' | 'Y') => { + status = match perform( + action, + snapshot.backend, + selected_row(&snapshot, selected), + ) { + Ok(message) => match load(snapshot.backend) { + Ok(next) => { + snapshot = next; + selected = + selected.min(snapshot.rows.len().saturating_sub(1)); + details.clear(); + message + } + Err(error) => format!("{message}; refresh failed: {error}"), + }, + Err(error) => error, + }; + pending = None; + } + KeyCode::Char('n' | 'N') | KeyCode::Esc => { + status = "Action cancelled".into(); + pending = None; + } + _ => {} + } + continue; + } + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + selected = selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + selected = (selected + 1).min(snapshot.rows.len().saturating_sub(1)); + } + KeyCode::Tab => match next_snapshot(snapshot.backend) { + Ok(next) => { + snapshot = next; + selected = 0; + details.clear(); + status = format!("{} items", snapshot.rows.len()); + } + Err(error) => status = error, + }, + KeyCode::Char('r' | 'R') => match load(snapshot.backend) { + Ok(next) => { + snapshot = next; + selected = selected.min(snapshot.rows.len().saturating_sub(1)); + status = format!("refreshed {} items", snapshot.rows.len()); + } + Err(error) => status = error, + }, + KeyCode::Enter => { + show_details(snapshot.backend, selected_row(&snapshot, selected), false) + .map(|value| details = value) + .unwrap_or_else(|error| status = error); + } + KeyCode::Char('l' | 'L') => { + show_details(snapshot.backend, selected_row(&snapshot, selected), true) + .map(|value| details = value) + .unwrap_or_else(|error| status = error); + } + KeyCode::Char('s' | 'S') + if matches!(snapshot.backend, Backend::Docker | Backend::Apple) => + { + pending = selected_row(&snapshot, selected).map(|_| Action::Toggle); + } + KeyCode::Char('x' | 'X') if snapshot.backend == Backend::Docker => { + pending = selected_row(&snapshot, selected).map(|_| Action::Restart); + } + KeyCode::Char('c' | 'C') if snapshot.backend == Backend::Slurm => { + pending = selected_row(&snapshot, selected).map(|_| Action::Cancel); + } + _ => {} + } + } + Event::Resize(_, _) => {} + _ => {} + } + } + Ok(0) +} + +fn selected_row(snapshot: &Snapshot, selected: usize) -> Option<&Row> { + snapshot.rows.get(selected) +} + +fn next_snapshot(current: Backend) -> Result { + let index = Backend::ALL + .iter() + .position(|backend| *backend == current) + .unwrap_or(0); + let mut errors = Vec::new(); + for offset in 1..=Backend::ALL.len() { + let backend = Backend::ALL[(index + offset) % Backend::ALL.len()]; + match load(backend) { + Ok(snapshot) => return Ok(snapshot), + Err(error) => errors.push(format!("{}: {error}", backend.name())), + } + } + Err(errors.join("; ")) +} + +fn perform(action: Action, backend: Backend, row: Option<&Row>) -> Result { + let row = row.ok_or_else(|| "nothing is selected".to_owned())?; + if !safe_id(&row.id) { + return Err("backend returned an unsafe item ID".into()); + } + match (backend, action) { + (Backend::Docker, Action::Toggle) => { + let (verb, past) = if row.state.eq_ignore_ascii_case("running") { + ("stop", "stopped") + } else { + ("start", "started") + }; + command_output("docker", &["container", verb, &row.id], false)?; + Ok(format!("{past} {}", row.name)) + } + (Backend::Apple, Action::Toggle) => { + let (verb, past) = if row.state.eq_ignore_ascii_case("running") { + ("stop", "stopped") + } else { + ("start", "started") + }; + command_output("container", &[verb, &row.id], false)?; + Ok(format!("{past} {}", row.name)) + } + (Backend::Docker, Action::Restart) => { + command_output("docker", &["container", "restart", &row.id], false)?; + Ok(format!("restarted {}", row.name)) + } + (Backend::Slurm, Action::Cancel) => { + command_output("scancel", &[&row.id], false)?; + Ok(format!("cancelled {}", row.id)) + } + _ => Err("that action is not available for this backend".into()), + } +} + +fn show_details(backend: Backend, row: Option<&Row>, logs: bool) -> Result, String> { + let row = row.ok_or_else(|| "nothing is selected".to_owned())?; + if !safe_id(&row.id) { + return Err("backend returned an unsafe item ID".into()); + } + let output = match (backend, logs) { + (Backend::Docker, false) => command_output("docker", &["inspect", &row.id], false)?, + (Backend::Docker, true) => { + command_output("docker", &["logs", "--tail", "40", &row.id], true)? + } + (Backend::Apple, false) => command_output("container", &["inspect", &row.id], false)?, + (Backend::Apple, true) => { + command_output("container", &["logs", "-n", "40", &row.id], true)? + } + (Backend::Slurm, false) => command_output("scontrol", &["show", "job", &row.id], false)?, + (Backend::Slurm, true) => return Err("Slurm log paths are shown in job details".into()), + }; + let lines = output.lines().map(clean).take(100).collect::>(); + Ok(if lines.is_empty() { + vec!["No output".into()] + } else { + lines + }) +} + +fn action_name(action: Action, backend: Backend, row: Option<&Row>) -> &'static str { + match (action, backend, row.map(|row| row.state.as_str())) { + (Action::Toggle, _, Some(state)) if state.eq_ignore_ascii_case("running") => "stop", + (Action::Toggle, _, _) => "start", + (Action::Restart, _, _) => "restart", + (Action::Cancel, _, _) => "cancel", + } +} + +fn draw( + stdout: &mut W, + snapshot: &Snapshot, + selected: usize, + details: &[String], + status: &str, + pending: Option, + (width, height): (u16, u16), +) -> io::Result<()> { + let color = use_color(false); + queue!( + stdout, + BeginSynchronizedUpdate, + MoveTo(0, 0), + Clear(ClearType::All) + )?; + if width < 80 || height < 20 { + write_line( + stdout, + 0, + height / 2, + width, + "Resize to at least 80 x 20", + color.then_some(Color::Yellow), + )?; + queue!(stdout, EndSynchronizedUpdate)?; + return stdout.flush(); + } + + write_line( + stdout, + 0, + 1, + width, + &format!( + "+-- LAZYBOX // {} --+", + snapshot.backend.name().to_uppercase() + ), + color.then_some(Color::Cyan), + )?; + write_line( + stdout, + 2, + 3, + width, + " ID NAME SOURCE STATE DETAIL", + color.then_some(Color::Grey), + )?; + + let visible = usize::from(height.saturating_sub(11).min(10)); + let start = selected.saturating_sub(visible.saturating_sub(1)); + for (offset, row) in snapshot.rows.iter().skip(start).take(visible).enumerate() { + let index = start + offset; + let line = format!( + "{} {:14} {:18} {:22} {:11} {}", + if index == selected { '>' } else { ' ' }, + fit(&row.id, 14), + fit(&row.name, 18), + fit(&row.source, 22), + fit(&row.state, 11), + row.detail + ); + write_line( + stdout, + 2, + 4 + offset as u16, + width, + &line, + color.then_some(if index == selected { + Color::Green + } else { + Color::White + }), + )?; + } + + let detail_row = 5 + visible as u16; + write_line( + stdout, + 2, + detail_row, + width, + "DETAIL", + color.then_some(Color::Cyan), + )?; + for (offset, line) in details + .iter() + .take(usize::from(height.saturating_sub(detail_row + 4))) + .enumerate() + { + write_line( + stdout, + 2, + detail_row + 1 + offset as u16, + width, + line, + color.then_some(Color::White), + )?; + } + + let status = pending.map_or_else( + || status.to_owned(), + |action| { + format!( + "Confirm {} {}? y/n", + action_name(action, snapshot.backend, selected_row(snapshot, selected)), + selected_row(snapshot, selected).map_or("", |row| row.id.as_str()) + ) + }, + ); + write_line( + stdout, + 2, + height - 2, + width, + &status, + color.then_some(if pending.is_some() { + Color::Yellow + } else { + Color::Grey + }), + )?; + write_line( + stdout, + 2, + height - 1, + width, + "Up/Down select Tab backend Enter details l logs s start/stop x restart c cancel r refresh q quit", + color.then_some(Color::Grey), + )?; + queue!(stdout, EndSynchronizedUpdate)?; + stdout.flush() +} + +fn fit(value: &str, width: usize) -> String { + let value = value.chars().take(width).collect::(); + format!("{value:( + stdout: &mut W, + column: u16, + row: u16, + width: u16, + value: &str, + color: Option, +) -> io::Result<()> { + let value = value + .chars() + .take(usize::from(width.saturating_sub(column))) + .collect::(); + queue!(stdout, MoveTo(column, row))?; + if let Some(color) = color { + queue!(stdout, SetForegroundColor(color))?; + } + queue!(stdout, Print(value))?; + if color.is_some() { + queue!(stdout, ResetColor)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_each_backend_and_removes_terminal_controls() { + assert_eq!( + parse_docker("abc\tweb\tnginx\trunning\tUp 2 minutes\n")[0].name, + "web" + ); + assert_eq!( + parse_apple("ID IMAGE OS ARCH STATE ADDR\nweb alpine linux arm64 running 10.0.0.2\n") + [0] + .state, + "running" + ); + assert_eq!( + parse_slurm("42|train|keys|R|01:02|node1|gpu\n")[0].source, + "gpu" + ); + assert_eq!(clean("safe\x1b[2J text\n"), "safe[2J text"); + assert_eq!(success_text(b"", b"stderr log\n", true), "stderr log\n"); + assert_eq!( + read_limited(std::io::Cursor::new(b"12345"), 3).expect("bounded read"), + b"1234" + ); + + let snapshot = Snapshot { + backend: Backend::Docker, + rows: parse_docker("abc\tweb\tnginx\trunning\tUp\n"), + }; + let mut screen = Vec::new(); + draw(&mut screen, &snapshot, 0, &[], "ready", None, (80, 20)).expect("render dashboard"); + assert!(String::from_utf8_lossy(&screen).contains("STATE DETAIL")); + } + + #[test] + fn validates_cli_and_backend_ids() { + let options = parse_options(vec![ + "snapshot".into(), + "--backend".into(), + "slurm".into(), + "--json".into(), + ]) + .expect("valid options"); + assert_eq!(options.mode, Some(Mode::Snapshot)); + assert_eq!(options.backend, Some(Backend::Slurm)); + assert_eq!(options.format, Some(Format::Json)); + assert!(parse_options(vec!["--plain".into(), "--json".into()]).is_err()); + assert!(Backend::parse("missing").is_err()); + assert!(safe_id("123_[1-4%2]")); + assert!(!safe_id("--all")); + assert!(!safe_id("bad id")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 19d4c6d..15c8c03 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ pub mod fetch; pub mod games; +pub mod lazybox; +mod terminal; use std::fmt::Write as _; use std::io::{self, IsTerminal as _}; diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..ac93748 --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,40 @@ +use std::io; + +use crossterm::cursor::{Hide, Show}; +use crossterm::execute; +use crossterm::style::ResetColor; +use crossterm::terminal::{ + DisableLineWrap, EnableLineWrap, EndSynchronizedUpdate, EnterAlternateScreen, + LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; + +pub(crate) struct Session; + +impl Session { + pub(crate) fn enter() -> io::Result { + enable_raw_mode()?; + if let Err(error) = execute!(io::stdout(), EnterAlternateScreen, Hide, DisableLineWrap) { + restore(); + return Err(error); + } + Ok(Self) + } +} + +impl Drop for Session { + fn drop(&mut self) { + restore(); + } +} + +fn restore() { + let _ = execute!( + io::stdout(), + EndSynchronizedUpdate, + ResetColor, + Show, + EnableLineWrap, + LeaveAlternateScreen + ); + let _ = disable_raw_mode(); +} diff --git a/tests/cli.rs b/tests/cli.rs index 1a5d5bc..a7acc14 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -4,6 +4,7 @@ fn command(binary: &str) -> Command { let path = match binary { "fetch" => env!("CARGO_BIN_EXE_fetch"), "games" => env!("CARGO_BIN_EXE_games"), + "lazybox" => env!("CARGO_BIN_EXE_lazybox"), _ => unreachable!(), }; let mut command = Command::new(path); @@ -74,3 +75,34 @@ fn games_describes_native_modes_and_requires_a_terminal_to_play() { assert_eq!(non_tty.status.code(), Some(2)); assert!(String::from_utf8_lossy(&non_tty.stderr).contains("need a terminal")); } + +#[test] +fn lazybox_has_a_stable_cli_without_needing_a_backend() { + let help = command("lazybox") + .arg("--help") + .output() + .expect("show help"); + assert!(help.status.success()); + assert!(String::from_utf8_lossy(&help.stdout).contains("lazybox snapshot")); + + let invalid = command("lazybox") + .args(["snapshot", "--backend", "missing"]) + .output() + .expect("reject backend"); + assert_eq!(invalid.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&invalid.stderr).contains("unknown backend")); + + let misplaced = command("lazybox") + .args(["open", "--plain"]) + .output() + .expect("reject snapshot format"); + assert_eq!(misplaced.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&misplaced.stderr).contains("snapshot options")); + + let non_tty = command("lazybox") + .arg("open") + .output() + .expect("reject non-terminal dashboard"); + assert_eq!(non_tty.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&non_tty.stderr).contains("needs a terminal")); +}