From ac3bc9b2820a98957cdd7816c8708acdc5cc950f Mon Sep 17 00:00:00 2001 From: Keys <70819367+keys-i@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:42:36 +1000 Subject: [PATCH 1/2] feat: add native apps workbench --- .github/workflows/benchmarks.yml | 2 +- Cargo.toml | 4 + README.md | 22 + benches/apps.md | 30 + benches/run.sh | 8 +- docs/research.md | 9 +- pyproject.toml | 2 +- scripts/smoke-wheel.sh | 1 + src/apps.rs | 1443 ++++++++++++++++++++++++++++++ src/bin/apps.rs | 9 + src/lib.rs | 1 + tests/cli.rs | 88 +- 12 files changed, 1613 insertions(+), 6 deletions(-) create mode 100644 benches/apps.md create mode 100644 src/apps.rs create mode 100644 src/bin/apps.rs diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 445c761..2021d44 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -37,7 +37,7 @@ concurrency: jobs: tools: - name: fetch + games + lazybox + science + screensaver + name: fetch + games + lazybox + science + screensaver + apps runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/Cargo.toml b/Cargo.toml index 4f15424..4fc95be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,10 @@ path = "src/bin/science.rs" name = "screensaver" path = "src/bin/screensaver.rs" +[[bin]] +name = "apps" +path = "src/bin/apps.rs" + [dependencies] crossterm = { version = "=0.29.0", default-features = false, features = ["events", "windows"] } diff --git a/README.md b/README.md index 05864e0..be2a752 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ Small native terminal tools with one job each: +- `apps` reads text, explores CSV/TSV, presents Markdown, paints text canvases, and runs timers. - `fetch` shows a fast system and CPU overview with styled, plain, or JSON output. - `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. @@ -31,6 +32,7 @@ Rust 1.97.1 is pinned for builds. ```sh cargo build --release --locked +./target/release/apps ./target/release/fetch ./target/release/games ./target/release/lazybox @@ -41,6 +43,10 @@ cargo build --release --locked Useful non-interactive forms: ```sh +apps snapshot read notes.md --find TODO --json +apps snapshot table data.csv --limit 25 --plain +apps snapshot slides talk.md --page 2 +apps snapshot timer --seconds 1500 --elapsed 60 --json fetch --plain fetch --json games list --json @@ -106,6 +112,20 @@ Interactive output updates only changed terminal rows, supports resize and `NO_COLOR`, and can be slowed to one frame per second with `--fps 1`. Plain and JSON snapshots provide a non-animated alternative. +## Native apps + +`apps` owns five local workflows in one binary: bounded UTF-8 reading and +search, quoted CSV/TSV parsing, Markdown slide navigation, text-canvas painting, +and a monotonic countdown timer. Every file input is opened once and capped at +32 MiB. Tables are additionally capped at 100,000 rows, 256 columns, and two +million cells. Paint output uses a completed temporary file and an atomic +no-overwrite hard link, so it cannot replace an existing file. + +The binary does not bundle or launch MuPDF, Firefox, a browser engine, a +Wayland compositor, a PTY server, TLS, weather/speed services, plugins, or +copied upstream code and assets. Pipe already-fetched text through `-` when a +local snapshot is enough. + ## Scope `fetch` has detailed native collection on macOS and Linux and a smaller @@ -120,6 +140,8 @@ The [science benchmark](benches/science.md) measures an offline orbit snapshot and binary size; file parsing latency remains input-dependent. The [screensaver benchmark](benches/screensaver.md) measures one deterministic generated snapshot and binary size; Git and GIF latency remains input-dependent. +The [apps benchmark](benches/apps.md) measures one deterministic timer snapshot +and binary size; file parsing and interactive latency remain input-dependent. ## Project diff --git a/benches/apps.md b/benches/apps.md new file mode 100644 index 0000000..b05e68e --- /dev/null +++ b/benches/apps.md @@ -0,0 +1,30 @@ +# Apps benchmark + +The benchmark measures `apps snapshot timer --seconds 1500 --elapsed 60 +--plain`, a deterministic local formatting path with no file, terminal, +network, cache, subprocess, or clock dependency. File and interactive latency +depends on input size and is not used as a general startup claim. + +Measured 2026-08-18 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 | Range | Stripped binary | +| --- | ---: | ---: | ---: | +| `apps snapshot timer --seconds 1500 --elapsed 60 --plain` | 1.6 ± 0.1 ms | 1.4–1.9 ms | 438,752 bytes | + +This is an owned startup measurement, not a claim that a timer snapshot is +equivalent to an upstream PDF renderer, browser, compositor, or network test. + +The macOS ARM64 wheel is 1,329,747 bytes for all six binaries, package +metadata, project and dependency licenses, and the CycloneDX SBOM. + +Run the same benchmark used by CI: + +```sh +cargo build --release --locked +RUNS=100 WARMUP=20 bash benches/run.sh +``` + +Hyperfine JSON, Markdown, and stripped binary sizes are written below ignored +`target/benchmarks/`. Record a same-host fixture, byte/row/cell count, median, +and spread before making a parser-performance claim. diff --git a/benches/run.sh b/benches/run.sh index c76e5a1..71627e2 100755 --- a/benches/run.sh +++ b/benches/run.sh @@ -10,12 +10,13 @@ games="$project/target/release/games" lazybox="$project/target/release/lazybox" science="$project/target/release/science" screensaver="$project/target/release/screensaver" +apps="$project/target/release/apps" command -v hyperfine >/dev/null 2>&1 || { echo "benchmarks: install hyperfine 1.20.0" >&2 exit 127 } -for binary in "$fetch" "$games" "$lazybox" "$science" "$screensaver"; do +for binary in "$fetch" "$games" "$lazybox" "$science" "$screensaver" "$apps"; do [[ -x "$binary" ]] || { echo "benchmarks: run cargo build --release --locked first" >&2 exit 2 @@ -31,13 +32,15 @@ hyperfine --shell=none --warmup "$warmup" --runs "$runs" \ --command-name games "$games list --plain" \ --command-name lazybox "$lazybox --version" \ --command-name science "$science snapshot orbit --plain" \ - --command-name screensaver "$screensaver snapshot rain --seed 1 --frame 24 --plain" + --command-name screensaver "$screensaver snapshot rain --seed 1 --frame 24 --plain" \ + --command-name apps "$apps snapshot timer --seconds 1500 --elapsed 60 --plain" fetch_size=$(wc -c < "$fetch" | tr -d ' ') games_size=$(wc -c < "$games" | tr -d ' ') lazybox_size=$(wc -c < "$lazybox" | tr -d ' ') science_size=$(wc -c < "$science" | tr -d ' ') screensaver_size=$(wc -c < "$screensaver" | tr -d ' ') +apps_size=$(wc -c < "$apps" | tr -d ' ') { echo "## Keys Tools benchmarks" echo @@ -54,6 +57,7 @@ screensaver_size=$(wc -c < "$screensaver" | tr -d ' ') echo "| lazybox | $lazybox_size |" echo "| science | $science_size |" echo "| screensaver | $screensaver_size |" + echo "| apps | $apps_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 35c0a00..6e5c803 100644 --- a/docs/research.md +++ b/docs/research.md @@ -1,6 +1,6 @@ # Terminal tool research -Reviewed 2026-08-12 through 2026-08-17 from the requested repositories. The +Reviewed 2026-08-12 through 2026-08-18 from the requested repositories. The local `research/` directory contains shallow clones at the revisions below and is intentionally ignored by Git. Category and license describe the upstream project; no source or assets are copied into Keys Tools. @@ -74,6 +74,13 @@ project; no source or assets are copied into Keys Tools. It does not copy upstream art or code, and it excludes live weather, geolocation, Tenor, URLs, videos, exports, plugins, and caches because those require unrelated privacy, TLS, codec, persistence, or subprocess surfaces. +- `apps` owns the common local workflows from the final ten projects: bounded + text reading/search, quoted CSV/TSV tables, Markdown slides, text-canvas + painting, and a monotonic timer. PDF rasterization requires MuPDF; modern web + rendering requires Firefox; GUI-in-terminal and terminal-window management + require Wayland/PTY engines; weather and throughput tests require TLS and + live services. Those unrelated engines remain excluded instead of being + hidden behind launchers or adding large dependency trees. - 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. diff --git a/pyproject.toml b/pyproject.toml index 698133f..4de3d9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.md" requires-python = ">=3.9" license = "MIT" authors = [{ name = "Keys" }] -keywords = ["astronomy", "cli", "containers", "games", "gif", "point-cloud", "screensaver", "slurm", "system-information", "terminal", "vcd", "waveform"] +keywords = ["astronomy", "cli", "containers", "csv", "games", "gif", "markdown", "point-cloud", "screensaver", "slurm", "system-information", "terminal", "timer", "vcd", "waveform"] classifiers = [ "Environment :: Console", "Programming Language :: Rust", diff --git a/scripts/smoke-wheel.sh b/scripts/smoke-wheel.sh index e45ed7e..d08b88f 100644 --- a/scripts/smoke-wheel.sh +++ b/scripts/smoke-wheel.sh @@ -39,3 +39,4 @@ fi "$bin_dir/lazybox$suffix" --version "$bin_dir/science$suffix" snapshot orbit --json "$bin_dir/screensaver$suffix" snapshot rain --seed 1 --json +"$bin_dir/apps$suffix" snapshot timer --seconds 60 --elapsed 5 --json diff --git a/src/apps.rs b/src/apps.rs new file mode 100644 index 0000000..45991dc --- /dev/null +++ b/src/apps.rs @@ -0,0 +1,1443 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufWriter, IsTerminal as _, Read as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use crossterm::cursor::MoveTo; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::queue; +use crossterm::style::Print; +use crossterm::terminal::{BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate, size}; + +use crate::terminal::Session; +use crate::{VERSION, json_string}; + +const HELP: &str = "Fast native local terminal apps\n\nUsage:\n apps [open|snapshot] read FILE [--find TEXT] [--limit N]\n apps [open|snapshot] table FILE [--delimiter comma|tab] [--limit N]\n apps [open|snapshot] slides FILE [--page N]\n apps [open|snapshot] paint [FILE] [--width N --height N] [--output FILE]\n apps [open|snapshot] timer --seconds N [--elapsed N] [--label TEXT]\n\nOptions:\n --find TEXT Find text in read mode\n --delimiter NAME comma or tab; inferred from .tsv otherwise\n --limit N Snapshot rows or lines (1..1000, default 20)\n --page N Slide number, starting at 1\n --width N New paint canvas width (1..240, default 40)\n --height N New paint canvas height (1..80, default 16)\n --output FILE New paint file; existing files are never overwritten\n --seconds N Timer duration (1..86400)\n --elapsed N Deterministic snapshot progress in seconds\n --label TEXT Timer label\n --plain Stable snapshot text\n --json Machine-readable snapshot\n -h, --help Show this help\n -V, --version Show the version\n\nControls: q quits; arrows navigate; PageUp/PageDown jump; Home/End move to edges. Slides use Left/Right. Paint uses a printable key or Space to draw, Backspace to erase, and s to save. Timer uses Space to pause and r to reset."; + +const MAX_INPUT_BYTES: u64 = 32 * 1024 * 1024; +const MAX_LINES: usize = 200_000; +const MAX_LINE_BYTES: usize = 64 * 1024; +const MAX_ROWS: usize = 100_000; +const MAX_COLUMNS: usize = 256; +const MAX_CELLS: usize = 2_000_000; +const MAX_FIELD_BYTES: usize = 64 * 1024; +const MAX_SLIDES: usize = 512; +const MAX_PAINT_WIDTH: usize = 240; +const MAX_PAINT_HEIGHT: usize = 80; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Action { + Open, + Snapshot, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum View { + Read, + Table, + Slides, + Paint, + Timer, +} + +impl View { + fn parse(value: &str) -> Result { + match value { + "read" => Ok(Self::Read), + "table" => Ok(Self::Table), + "slides" => Ok(Self::Slides), + "paint" => Ok(Self::Paint), + "timer" => Ok(Self::Timer), + _ => Err(format!( + "unknown app {value:?}; expected read, table, slides, paint, or timer" + )), + } + } + + fn name(self) -> &'static str { + match self { + Self::Read => "read", + Self::Table => "table", + Self::Slides => "slides", + Self::Paint => "paint", + Self::Timer => "timer", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Format { + Plain, + Json, +} + +#[derive(Debug, PartialEq, Eq)] +struct Options { + action: Action, + view: View, + input: Option, + format: Format, + find: Option, + delimiter: Option, + limit: usize, + page: usize, + width: usize, + height: usize, + output: Option, + seconds: u64, + elapsed: u64, + label: String, +} + +pub fn run(arguments: Vec) -> Result { + match arguments.as_slice() { + [] => { + write_stdout(HELP)?; + return Ok(0); + } + [argument] if matches!(argument.as_str(), "-h" | "--help") => { + write_stdout(HELP)?; + return Ok(0); + } + [argument] if matches!(argument.as_str(), "-V" | "--version") => { + write_stdout(&format!("apps {VERSION}"))?; + return Ok(0); + } + _ => {} + } + + let terminal = io::stdin().is_terminal() && io::stdout().is_terminal(); + let options = parse_options(arguments, terminal)?; + if options.action == Action::Open && !terminal { + return Err("interactive apps need a terminal; use 'apps snapshot'".into()); + } + if options.action == Action::Open && options.input.as_deref() == Some(Path::new("-")) { + return Err("interactive input cannot also be stdin; use a file or snapshot '-'".into()); + } + + match (options.action, options.view) { + (Action::Snapshot, View::Read) => { + snapshot_read(load_text(required_input(&options)?)?, &options) + } + (Action::Snapshot, View::Table) => snapshot_table( + load_table(required_input(&options)?, options.delimiter)?, + &options, + ), + (Action::Snapshot, View::Slides) => { + snapshot_slides(load_slides(required_input(&options)?)?, &options) + } + (Action::Snapshot, View::Paint) => snapshot_paint( + load_paint(options.input.as_deref(), options.width, options.height)?, + &options, + ), + (Action::Snapshot, View::Timer) => snapshot_timer(&options), + (Action::Open, View::Read) => open_read(load_text(required_input(&options)?)?, &options), + (Action::Open, View::Table) => { + open_table(load_table(required_input(&options)?, options.delimiter)?) + } + (Action::Open, View::Slides) => { + open_slides(load_slides(required_input(&options)?)?, options.page) + } + (Action::Open, View::Paint) => open_paint( + load_paint(options.input.as_deref(), options.width, options.height)?, + options.output.as_deref(), + ), + (Action::Open, View::Timer) => open_timer(options.seconds, &options.label), + } +} + +fn parse_options(arguments: Vec, terminal: bool) -> Result { + let mut action = None; + let mut view = None; + let mut input = None; + let mut format = None; + let mut find = None; + let mut delimiter = None; + let mut limit = 20_usize; + let mut limit_set = false; + let mut page = 1_usize; + let mut page_set = false; + let mut width = 40_usize; + let mut width_set = false; + let mut height = 16_usize; + let mut height_set = false; + let mut output = None; + let mut seconds = None; + let mut elapsed = 0_u64; + let mut elapsed_set = false; + let mut label = "focus".to_owned(); + let mut label_set = false; + let mut arguments = arguments.into_iter(); + + while let Some(argument) = arguments.next() { + match argument.as_str() { + "open" if action.is_none() && view.is_none() => action = Some(Action::Open), + "snapshot" if action.is_none() && view.is_none() => action = Some(Action::Snapshot), + value @ ("read" | "table" | "slides" | "paint" | "timer") if view.is_none() => { + view = Some(View::parse(value)?); + } + "--find" if find.is_none() => { + let value = arguments.next().ok_or("--find needs text")?; + if value.is_empty() { + return Err("--find text cannot be empty".into()); + } + find = Some(value); + } + "--delimiter" if delimiter.is_none() => { + delimiter = Some(match arguments.next().as_deref() { + Some("comma") => ',', + Some("tab") => '\t', + _ => return Err("--delimiter must be comma or tab".into()), + }); + } + "--limit" if !limit_set => { + limit = number(&mut arguments, "--limit", 1, 1000)? as usize; + limit_set = true; + } + "--page" if !page_set => { + page = number(&mut arguments, "--page", 1, MAX_SLIDES as u64)? as usize; + page_set = true; + } + "--width" if !width_set => { + width = number(&mut arguments, "--width", 1, MAX_PAINT_WIDTH as u64)? as usize; + width_set = true; + } + "--height" if !height_set => { + height = number(&mut arguments, "--height", 1, MAX_PAINT_HEIGHT as u64)? as usize; + height_set = true; + } + "--output" if output.is_none() => { + output = Some(PathBuf::from( + arguments.next().ok_or("--output needs a file")?, + )); + } + "--seconds" if seconds.is_none() => { + seconds = Some(number(&mut arguments, "--seconds", 1, 86_400)?); + } + "--elapsed" if !elapsed_set => { + elapsed = number(&mut arguments, "--elapsed", 0, 86_400)?; + elapsed_set = true; + } + "--label" if !label_set => { + label = arguments.next().ok_or("--label needs text")?; + if label.trim().is_empty() || label.chars().any(char::is_control) { + return Err("--label must contain printable text".into()); + } + label_set = true; + } + "--plain" if format.is_none() => format = Some(Format::Plain), + "--json" if format.is_none() => format = Some(Format::Json), + "--plain" | "--json" => return Err("--plain and --json cannot be combined".into()), + value if value.starts_with('-') && value != "-" => { + return Err(format!("unknown option {value:?}; try 'apps --help'")); + } + value if input.is_none() => input = Some(PathBuf::from(value)), + value => return Err(format!("unexpected argument {value:?}")), + } + } + + let view = view.ok_or("choose read, table, slides, paint, or timer")?; + let action = action.unwrap_or(if terminal && format.is_none() { + Action::Open + } else { + Action::Snapshot + }); + if action == Action::Open && format.is_some() { + return Err("--plain and --json are snapshot options".into()); + } + if action == Action::Open && limit_set { + return Err("--limit is only valid with snapshot read or table".into()); + } + if matches!(view, View::Read | View::Table | View::Slides) && input.is_none() { + return Err(format!("{} needs an input file", view.name())); + } + if view == View::Timer && input.is_some() { + return Err("timer does not accept an input file".into()); + } + if view != View::Read && find.is_some() { + return Err("--find is only valid with read".into()); + } + if view != View::Table && delimiter.is_some() { + return Err("--delimiter is only valid with table".into()); + } + if !matches!(view, View::Read | View::Table) && limit_set { + return Err("--limit is only valid with read or table".into()); + } + if view != View::Slides && page_set { + return Err("--page is only valid with slides".into()); + } + if view != View::Paint && (width_set || height_set || output.is_some()) { + return Err("--width, --height, and --output are only valid with paint".into()); + } + if view == View::Paint && input.is_some() && (width_set || height_set) { + return Err("--width and --height only apply to a new paint canvas".into()); + } + if action == Action::Snapshot && output.is_some() { + return Err("--output is only valid with open paint".into()); + } + if view != View::Timer && (seconds.is_some() || elapsed_set || label_set) { + return Err("--seconds, --elapsed, and --label are only valid with timer".into()); + } + let seconds = if view == View::Timer { + seconds.ok_or("timer needs --seconds")? + } else { + 1 + }; + if elapsed > seconds { + return Err("--elapsed cannot exceed --seconds".into()); + } + if action == Action::Open && elapsed_set { + return Err("--elapsed is only valid with snapshot timer".into()); + } + + Ok(Options { + action, + view, + input, + format: format.unwrap_or(Format::Plain), + find, + delimiter, + limit, + page, + width, + height, + output, + seconds, + elapsed, + label, + }) +} + +fn number( + arguments: &mut impl Iterator, + flag: &str, + minimum: u64, + maximum: u64, +) -> Result { + let value = arguments + .next() + .ok_or_else(|| format!("{flag} needs a number"))? + .parse::() + .map_err(|_| format!("{flag} needs an integer from {minimum} to {maximum}"))?; + if !(minimum..=maximum).contains(&value) { + return Err(format!("{flag} must be between {minimum} and {maximum}")); + } + Ok(value) +} + +fn required_input(options: &Options) -> Result<&Path, String> { + options + .input + .as_deref() + .ok_or_else(|| format!("{} needs an input file", options.view.name())) +} + +fn write_stdout(value: &str) -> Result { + let mut stdout = io::stdout().lock(); + writeln!(stdout, "{value}").map_err(|error| format!("cannot write output: {error}"))?; + Ok(0) +} + +fn human_text(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + for character in value.chars() { + if character.is_control() { + output.extend(character.escape_default()); + } else { + output.push(character); + } + } + output +} + +fn human_path(path: &Path) -> String { + human_text(&path.to_string_lossy()) +} + +fn read_input(path: &Path) -> Result<(String, Vec), String> { + let mut bytes = Vec::new(); + let name; + if path == Path::new("-") { + name = "stdin".to_owned(); + io::stdin() + .lock() + .take(MAX_INPUT_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("cannot read stdin: {error}"))?; + } else { + let file = File::open(path) + .map_err(|error| format!("cannot open {}: {error}", human_path(path)))?; + let metadata = file + .metadata() + .map_err(|error| format!("cannot inspect {}: {error}", human_path(path)))?; + if !metadata.is_file() { + return Err(format!("input is not a file: {}", human_path(path))); + } + if metadata.len() > MAX_INPUT_BYTES { + return Err("input exceeds the 32 MiB limit".into()); + } + bytes.reserve(metadata.len() as usize + 1); + file.take(MAX_INPUT_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("cannot read {}: {error}", human_path(path)))?; + name = human_path(path); + } + if bytes.len() as u64 > MAX_INPUT_BYTES { + return Err("input exceeds the 32 MiB limit".into()); + } + Ok((name, bytes)) +} + +struct TextDocument { + name: String, + bytes: usize, + lines: Vec, +} + +fn load_text(path: &Path) -> Result { + let (name, bytes) = read_input(path)?; + let bytes_len = bytes.len(); + let text = String::from_utf8(bytes).map_err(|_| "input is not valid UTF-8 text")?; + let mut lines = Vec::new(); + for line in text.lines() { + if line.len() > MAX_LINE_BYTES { + return Err("input contains a line longer than 64 KiB".into()); + } + if line + .chars() + .any(|character| character.is_control() && character != '\t') + { + return Err("input contains an unsupported control character".into()); + } + if lines.len() >= MAX_LINES { + return Err("input exceeds the 200,000-line limit".into()); + } + lines.push(line.trim_end_matches('\r').to_owned()); + } + if lines.is_empty() { + lines.push(String::new()); + } + Ok(TextDocument { + name, + bytes: bytes_len, + lines, + }) +} + +fn matches(document: &TextDocument, query: Option<&str>) -> Vec { + let Some(query) = query else { + return Vec::new(); + }; + let query = query.to_lowercase(); + document + .lines + .iter() + .enumerate() + .filter_map(|(index, line)| line.to_lowercase().contains(&query).then_some(index)) + .collect() +} + +fn snapshot_read(document: TextDocument, options: &Options) -> Result { + let found = matches(&document, options.find.as_deref()); + let visible = document + .lines + .iter() + .take(options.limit) + .collect::>(); + match options.format { + Format::Plain => { + let mut output = format!( + "READ\t{}\t{} bytes\t{} lines\t{} matches", + document.name, + document.bytes, + document.lines.len(), + found.len() + ); + for (index, line) in visible.into_iter().enumerate() { + output.push_str(&format!("\n{}\t{}", index + 1, line)); + } + write_stdout(&output) + } + Format::Json => { + let lines = visible + .into_iter() + .map(|line| json_string(line)) + .collect::>() + .join(","); + write_stdout(&format!( + "{{\"app\":\"read\",\"source\":{},\"bytes\":{},\"lineCount\":{},\"matchCount\":{},\"lines\":[{}]}}", + json_string(&document.name), + document.bytes, + document.lines.len(), + found.len(), + lines + )) + } + } +} + +struct Table { + name: String, + delimiter: char, + rows: Vec>, + columns: usize, +} + +fn load_table(path: &Path, delimiter: Option) -> Result { + let (name, bytes) = read_input(path)?; + let text = String::from_utf8(bytes).map_err(|_| "table is not valid UTF-8 text")?; + let delimiter = delimiter.unwrap_or_else(|| { + if path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("tsv")) + { + '\t' + } else { + ',' + } + }); + let rows = parse_delimited(&text, delimiter)?; + let columns = rows.iter().map(Vec::len).max().unwrap_or(0); + if columns == 0 { + return Err("table has no columns".into()); + } + Ok(Table { + name, + delimiter, + rows, + columns, + }) +} + +fn parse_delimited(text: &str, delimiter: char) -> Result>, String> { + let mut rows = Vec::new(); + let mut row = Vec::new(); + let mut field = String::new(); + let mut quoted = false; + let mut after_quote = false; + let mut cells = 0_usize; + let mut characters = text.chars().peekable(); + + while let Some(character) = characters.next() { + if character.is_control() && !matches!(character, '\n' | '\r' | '\t') { + return Err("table contains an unsupported control character".into()); + } + if quoted { + if character == '"' { + if characters.peek() == Some(&'"') { + characters.next(); + field.push('"'); + } else { + quoted = false; + after_quote = true; + } + } else { + field.push(character); + } + } else if after_quote { + if character == delimiter { + push_field(&mut row, &mut field)?; + after_quote = false; + } else if character == '\n' || character == '\r' { + if character == '\r' && characters.peek() == Some(&'\n') { + characters.next(); + } + push_field(&mut row, &mut field)?; + push_row(&mut rows, &mut row, &mut cells)?; + after_quote = false; + } else { + return Err("table has characters after a closing quote".into()); + } + } else if character == '"' && field.is_empty() { + quoted = true; + } else if character == delimiter { + push_field(&mut row, &mut field)?; + } else if character == '\n' || character == '\r' { + if character == '\r' && characters.peek() == Some(&'\n') { + characters.next(); + } + push_field(&mut row, &mut field)?; + push_row(&mut rows, &mut row, &mut cells)?; + } else if character == '"' { + return Err("table has a quote inside an unquoted field".into()); + } else { + field.push(character); + } + if field.len() > MAX_FIELD_BYTES { + return Err("table field exceeds the 64 KiB limit".into()); + } + } + if quoted { + return Err("table has an unterminated quoted field".into()); + } + if after_quote || !field.is_empty() || !row.is_empty() { + push_field(&mut row, &mut field)?; + push_row(&mut rows, &mut row, &mut cells)?; + } + if rows.is_empty() { + return Err("table is empty".into()); + } + Ok(rows) +} + +fn push_field(row: &mut Vec, field: &mut String) -> Result<(), String> { + if row.len() >= MAX_COLUMNS { + return Err("table exceeds the 256-column limit".into()); + } + row.push(std::mem::take(field)); + Ok(()) +} + +fn push_row( + rows: &mut Vec>, + row: &mut Vec, + cells: &mut usize, +) -> Result<(), String> { + if rows.len() >= MAX_ROWS { + return Err("table exceeds the 100,000-row limit".into()); + } + *cells = cells + .checked_add(row.len()) + .filter(|cells| *cells <= MAX_CELLS) + .ok_or("table exceeds the 2,000,000-cell limit")?; + rows.push(std::mem::take(row)); + Ok(()) +} + +fn snapshot_table(table: Table, options: &Options) -> Result { + let visible = table.rows.iter().take(options.limit).collect::>(); + match options.format { + Format::Plain => { + let mut output = format!( + "TABLE\t{}\t{} rows\t{} columns\t{}", + table.name, + table.rows.len().saturating_sub(1), + table.columns, + if table.delimiter == '\t' { + "tab" + } else { + "comma" + } + ); + for row in visible { + output.push('\n'); + output.push_str( + &row.iter() + .map(|field| { + field + .replace('\t', "\\t") + .replace('\n', "\\n") + .replace('\r', "\\r") + }) + .collect::>() + .join("\t"), + ); + } + write_stdout(&output) + } + Format::Json => { + let rows = visible + .into_iter() + .map(|row| { + format!( + "[{}]", + row.iter() + .map(|field| json_string(field)) + .collect::>() + .join(",") + ) + }) + .collect::>() + .join(","); + write_stdout(&format!( + "{{\"app\":\"table\",\"source\":{},\"rowCount\":{},\"columnCount\":{},\"delimiter\":{},\"rows\":[{}]}}", + json_string(&table.name), + table.rows.len().saturating_sub(1), + table.columns, + json_string(if table.delimiter == '\t' { + "tab" + } else { + "comma" + }), + rows + )) + } + } +} + +struct Slides { + name: String, + pages: Vec>, +} + +fn load_slides(path: &Path) -> Result { + let document = load_text(path)?; + let mut pages = vec![Vec::new()]; + for line in document.lines { + if line.trim() == "---" { + if pages.len() >= MAX_SLIDES { + return Err("slides exceed the 512-page limit".into()); + } + pages.push(Vec::new()); + } else { + pages.last_mut().expect("one page exists").push(line); + } + } + pages.retain(|page| page.iter().any(|line| !line.trim().is_empty())); + if pages.is_empty() { + return Err("slides contain no content".into()); + } + Ok(Slides { + name: document.name, + pages, + }) +} + +fn snapshot_slides(slides: Slides, options: &Options) -> Result { + let index = slide_index(options.page, slides.pages.len())?; + let page = &slides.pages[index]; + match options.format { + Format::Plain => { + let mut output = format!( + "SLIDES\t{}\tpage {}/{}", + slides.name, + index + 1, + slides.pages.len() + ); + for line in page { + output.push('\n'); + output.push_str(line); + } + write_stdout(&output) + } + Format::Json => { + let lines = page + .iter() + .map(|line| json_string(line)) + .collect::>() + .join(","); + write_stdout(&format!( + "{{\"app\":\"slides\",\"source\":{},\"page\":{},\"pageCount\":{},\"lines\":[{}]}}", + json_string(&slides.name), + index + 1, + slides.pages.len(), + lines + )) + } + } +} + +fn slide_index(page: usize, pages: usize) -> Result { + page.checked_sub(1) + .filter(|index| *index < pages) + .ok_or_else(|| format!("--page exceeds the {pages}-page deck")) +} + +struct Paint { + name: String, + width: usize, + height: usize, + cells: Vec, + cursor_x: usize, + cursor_y: usize, + brush: char, + status: String, +} + +fn load_paint(path: Option<&Path>, width: usize, height: usize) -> Result { + let (name, width, height, cells) = if let Some(path) = path { + let document = load_text(path)?; + let height = document.lines.len(); + let width = document + .lines + .iter() + .map(|line| line.chars().count()) + .max() + .unwrap_or(1); + if width == 0 || width > MAX_PAINT_WIDTH || height == 0 || height > MAX_PAINT_HEIGHT { + return Err("paint input must fit within 240 columns and 80 rows".into()); + } + let mut cells = vec![' '; width * height]; + for (y, line) in document.lines.iter().enumerate() { + for (x, character) in line.chars().enumerate() { + if character.is_control() { + return Err("paint input contains a control character".into()); + } + cells[y * width + x] = character; + } + } + (document.name, width, height, cells) + } else { + ( + "new canvas".to_owned(), + width, + height, + vec![' '; width * height], + ) + }; + Ok(Paint { + name, + width, + height, + cells, + cursor_x: 0, + cursor_y: 0, + brush: '#', + status: String::new(), + }) +} + +impl Paint { + fn lines(&self, cursor: bool) -> Vec { + self.cells + .chunks(self.width) + .enumerate() + .map(|(y, row)| { + row.iter() + .enumerate() + .map(|(x, character)| { + if cursor && x == self.cursor_x && y == self.cursor_y && *character == ' ' { + '▯' + } else { + *character + } + }) + .collect() + }) + .collect() + } +} + +fn snapshot_paint(paint: Paint, options: &Options) -> Result { + let lines = paint.lines(false); + match options.format { + Format::Plain => { + let mut output = format!("PAINT\t{}\t{}x{}", paint.name, paint.width, paint.height); + for line in lines { + output.push('\n'); + output.push_str(line.trim_end()); + } + write_stdout(&output) + } + Format::Json => { + let lines = lines + .iter() + .map(|line| json_string(line)) + .collect::>() + .join(","); + write_stdout(&format!( + "{{\"app\":\"paint\",\"source\":{},\"width\":{},\"height\":{},\"lines\":[{}]}}", + json_string(&paint.name), + paint.width, + paint.height, + lines + )) + } + } +} + +fn snapshot_timer(options: &Options) -> Result { + let remaining = options.seconds - options.elapsed; + match options.format { + Format::Plain => write_stdout(&format!( + "TIMER\t{}\t{} elapsed\t{} remaining\t{} total", + options.label, options.elapsed, remaining, options.seconds + )), + Format::Json => write_stdout(&format!( + "{{\"app\":\"timer\",\"label\":{},\"elapsedSeconds\":{},\"remainingSeconds\":{},\"totalSeconds\":{}}}", + json_string(&options.label), + options.elapsed, + remaining, + options.seconds + )), + } +} + +struct TerminalFrame { + previous: Vec, + dimensions: (u16, u16), +} + +impl TerminalFrame { + fn new() -> Self { + Self { + previous: Vec::new(), + dimensions: (0, 0), + } + } + + fn draw( + &mut self, + title: &str, + detail: &str, + body: &[String], + footer: &str, + ) -> Result<(), String> { + let (width, height) = size().unwrap_or((80, 24)); + let width = width.clamp(1, 240); + let height = height.clamp(1, 80); + let mut lines = vec![String::new(); usize::from(height)]; + if width < 50 || height < 16 { + lines[usize::from(height / 2)] = centered("Resize to at least 50 x 16", width); + } else { + lines[1] = centered(&format!("+-- APPS // {} --+", title.to_uppercase()), width); + lines[2] = centered(detail, width); + let available = usize::from(height.saturating_sub(7)); + for (target, source) in lines[4..4 + available].iter_mut().zip(body) { + *target = fit(source, usize::from(width)); + } + lines[usize::from(height - 2)] = centered(footer, width); + } + + let mut stdout = BufWriter::new(io::stdout()); + if self.dimensions != (width, height) { + queue!(stdout, Clear(ClearType::All)).map_err(draw_error)?; + self.previous = vec![String::new(); lines.len()]; + self.dimensions = (width, height); + } + queue!(stdout, BeginSynchronizedUpdate).map_err(draw_error)?; + for (y, line) in lines.iter().enumerate() { + if self.previous.get(y) == Some(line) { + continue; + } + queue!( + stdout, + MoveTo(0, y as u16), + Clear(ClearType::CurrentLine), + Print(line) + ) + .map_err(draw_error)?; + } + queue!(stdout, EndSynchronizedUpdate).map_err(draw_error)?; + stdout.flush().map_err(draw_error)?; + self.previous = lines; + Ok(()) + } +} + +fn draw_error(error: io::Error) -> String { + format!("cannot draw terminal: {error}") +} + +fn fit(value: &str, width: usize) -> String { + value.chars().take(width).collect() +} + +fn centered(value: &str, width: u16) -> String { + let value = fit(value, usize::from(width)); + let padding = usize::from(width).saturating_sub(value.chars().count()) / 2; + format!("{}{}", " ".repeat(padding), value) +} + +fn body_height() -> usize { + usize::from(size().unwrap_or((80, 24)).1.min(80).saturating_sub(7)).max(1) +} + +fn read_key(wait: Duration) -> Result, String> { + if !event::poll(wait).map_err(|error| format!("cannot read terminal: {error}"))? { + return Ok(None); + } + let event = event::read().map_err(|error| format!("cannot read terminal: {error}"))?; + let Event::Key(key) = event else { + return Ok(None); + }; + Ok(matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat).then_some(key)) +} + +fn quits(key: KeyEvent) -> bool { + matches!(key.code, KeyCode::Esc | KeyCode::Char('q' | 'Q')) + || key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('c' | 'C')) +} + +fn open_read(document: TextDocument, options: &Options) -> Result { + let _session = Session::enter().map_err(|error| format!("cannot prepare terminal: {error}"))?; + let mut frame = TerminalFrame::new(); + let found = matches(&document, options.find.as_deref()); + let mut offset = found.first().copied().unwrap_or(0); + loop { + let height = body_height(); + let body = document + .lines + .iter() + .enumerate() + .skip(offset) + .take(height) + .map(|(index, line)| { + format!( + "{} {:>6} {}", + if found.binary_search(&index).is_ok() { + '>' + } else { + ' ' + }, + index + 1, + line + ) + }) + .collect::>(); + frame.draw( + "read", + &format!( + "{} · {} lines · {} matches", + document.name, + document.lines.len(), + found.len() + ), + &body, + "Up/Down scroll PageUp/PageDown jump Home/End edges n next match q quit", + )?; + let Some(key) = read_key(Duration::from_secs(1))? else { + continue; + }; + if quits(key) { + break; + } + match key.code { + KeyCode::Up => offset = offset.saturating_sub(1), + KeyCode::Down => offset = (offset + 1).min(document.lines.len().saturating_sub(1)), + KeyCode::PageUp => offset = offset.saturating_sub(height), + KeyCode::PageDown => { + offset = (offset + height).min(document.lines.len().saturating_sub(1)); + } + KeyCode::Home => offset = 0, + KeyCode::End => offset = document.lines.len().saturating_sub(height), + KeyCode::Char('n' | 'N') if !found.is_empty() => { + offset = found + .iter() + .copied() + .find(|index| *index > offset) + .unwrap_or(found[0]); + } + _ => {} + } + } + Ok(0) +} + +fn open_table(table: Table) -> Result { + let _session = Session::enter().map_err(|error| format!("cannot prepare terminal: {error}"))?; + let mut frame = TerminalFrame::new(); + let mut row_offset = 0_usize; + let mut column_offset = 0_usize; + loop { + let (width, _) = size().unwrap_or((80, 24)); + let visible_columns = (usize::from(width.min(240)).saturating_sub(8) / 19).max(1); + let height = body_height().saturating_sub(2).max(1); + let mut body = Vec::new(); + if let Some(header) = table.rows.first() { + body.push(render_table_row(header, column_offset, visible_columns, 0)); + body.push("-".repeat(usize::from(width.min(240)))); + } + for (index, row) in table + .rows + .iter() + .enumerate() + .skip(row_offset + 1) + .take(height) + { + body.push(render_table_row(row, column_offset, visible_columns, index)); + } + frame.draw( + "table", + &format!( + "{} · {} rows × {} columns", + table.name, + table.rows.len().saturating_sub(1), + table.columns + ), + &body, + "Arrows scroll PageUp/PageDown jump Home/End edges q quit", + )?; + let Some(key) = read_key(Duration::from_secs(1))? else { + continue; + }; + if quits(key) { + break; + } + match key.code { + KeyCode::Up => row_offset = row_offset.saturating_sub(1), + KeyCode::Down => { + row_offset = (row_offset + 1).min(table.rows.len().saturating_sub(2)); + } + KeyCode::Left => column_offset = column_offset.saturating_sub(1), + KeyCode::Right => { + column_offset = (column_offset + 1).min(table.columns.saturating_sub(1)); + } + KeyCode::PageUp => row_offset = row_offset.saturating_sub(height), + KeyCode::PageDown => { + row_offset = (row_offset + height).min(table.rows.len().saturating_sub(2)); + } + KeyCode::Home => { + row_offset = 0; + column_offset = 0; + } + KeyCode::End => row_offset = table.rows.len().saturating_sub(height + 1), + _ => {} + } + } + Ok(0) +} + +fn render_table_row(row: &[String], offset: usize, columns: usize, index: usize) -> String { + let mut output = if index == 0 { + " HEADER ".to_owned() + } else { + format!("{:>7} ", index) + }; + for column in offset..(offset + columns) { + let value = row + .get(column) + .map_or("", String::as_str) + .replace(['\n', '\r', '\t'], " "); + output.push_str(&format!("{:<18} ", fit(&value, 18))); + } + output +} + +fn open_slides(slides: Slides, page: usize) -> Result { + let mut index = slide_index(page, slides.pages.len())?; + let _session = Session::enter().map_err(|error| format!("cannot prepare terminal: {error}"))?; + let mut frame = TerminalFrame::new(); + let mut offset = 0_usize; + loop { + let height = body_height(); + let body = slides.pages[index] + .iter() + .skip(offset) + .take(height) + .map(|line| markdown_line(line)) + .collect::>(); + frame.draw( + "slides", + &format!( + "{} · page {}/{}", + slides.name, + index + 1, + slides.pages.len() + ), + &body, + "Left/Right page Up/Down scroll Home/End first/last q quit", + )?; + let Some(key) = read_key(Duration::from_secs(1))? else { + continue; + }; + if quits(key) { + break; + } + match key.code { + KeyCode::Left | KeyCode::PageUp => { + index = index.saturating_sub(1); + offset = 0; + } + KeyCode::Right | KeyCode::PageDown => { + index = (index + 1).min(slides.pages.len() - 1); + offset = 0; + } + KeyCode::Up => offset = offset.saturating_sub(1), + KeyCode::Down => { + offset = (offset + 1).min(slides.pages[index].len().saturating_sub(1)); + } + KeyCode::Home => { + index = 0; + offset = 0; + } + KeyCode::End => { + index = slides.pages.len() - 1; + offset = 0; + } + _ => {} + } + } + Ok(0) +} + +fn markdown_line(line: &str) -> String { + let trimmed = line.trim_start(); + let heading = trimmed.trim_start_matches('#').trim_start(); + if heading.len() != trimmed.len() { + heading.to_uppercase() + } else { + line.to_owned() + } +} + +fn open_paint(mut paint: Paint, output: Option<&Path>) -> Result { + let _session = Session::enter().map_err(|error| format!("cannot prepare terminal: {error}"))?; + let mut frame = TerminalFrame::new(); + loop { + frame.draw( + "paint", + &format!( + "{} · {}×{} · brush {}{}{}", + paint.name, + paint.width, + paint.height, + paint.brush, + if paint.status.is_empty() { "" } else { " · " }, + paint.status + ), + &paint.lines(true), + "Arrows move printable/Space draw Backspace erase s save new file q quit", + )?; + let Some(key) = read_key(Duration::from_secs(1))? else { + continue; + }; + if quits(key) { + break; + } + paint.status.clear(); + match key.code { + KeyCode::Up => paint.cursor_y = paint.cursor_y.saturating_sub(1), + KeyCode::Down => paint.cursor_y = (paint.cursor_y + 1).min(paint.height - 1), + KeyCode::Left => paint.cursor_x = paint.cursor_x.saturating_sub(1), + KeyCode::Right => paint.cursor_x = (paint.cursor_x + 1).min(paint.width - 1), + KeyCode::Backspace | KeyCode::Delete => { + paint.cells[paint.cursor_y * paint.width + paint.cursor_x] = ' '; + } + KeyCode::Char('s' | 'S') => { + paint.status = match output { + Some(path) => { + save_paint(&paint, path).map(|()| format!("saved {}", human_path(path))) + } + None => Err("start paint with --output FILE to save".into()), + } + .unwrap_or_else(|error| error); + } + KeyCode::Char(' ') => { + paint.cells[paint.cursor_y * paint.width + paint.cursor_x] = paint.brush; + } + KeyCode::Char(character) + if !character.is_control() + && !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + paint.brush = character; + paint.cells[paint.cursor_y * paint.width + paint.cursor_x] = character; + } + _ => {} + } + } + Ok(0) +} + +fn save_paint(paint: &Paint, path: &Path) -> Result<(), String> { + if path.exists() { + return Err(format!("refusing to overwrite {}", human_path(path))); + } + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let file_name = path + .file_name() + .ok_or("paint output needs a file name")? + .to_string_lossy(); + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let temporary = parent.join(format!( + ".{file_name}.apps-{}-{nonce}.tmp", + std::process::id() + )); + let result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|error| format!("cannot create paint temporary file: {error}"))?; + for line in paint.lines(false) { + writeln!(file, "{}", line.trim_end()) + .map_err(|error| format!("cannot write paint output: {error}"))?; + } + file.sync_all() + .map_err(|error| format!("cannot sync paint output: {error}"))?; + drop(file); + fs::hard_link(&temporary, path) + .map_err(|error| format!("cannot create paint output {}: {error}", human_path(path)))?; + Ok(()) + })(); + let _ = fs::remove_file(&temporary); + result +} + +fn display_seconds(duration: Duration) -> u64 { + duration + .as_secs() + .saturating_add(u64::from(duration.subsec_nanos() > 0)) +} + +fn open_timer(seconds: u64, label: &str) -> Result { + let _session = Session::enter().map_err(|error| format!("cannot prepare terminal: {error}"))?; + let mut frame = TerminalFrame::new(); + let total = Duration::from_secs(seconds); + let mut accumulated = Duration::ZERO; + let mut started = Some(Instant::now()); + loop { + let elapsed = accumulated + started.map_or(Duration::ZERO, |instant| instant.elapsed()); + let remaining = total.saturating_sub(elapsed); + let display_remaining = display_seconds(remaining); + let completed = elapsed >= total; + let bar_width = 40_usize; + let filled = ((elapsed.as_secs_f64() / total.as_secs_f64()) * bar_width as f64) + .round() + .clamp(0.0, bar_width as f64) as usize; + let width = size().unwrap_or((80, 24)).0.min(240); + let body = vec![ + String::new(), + centered(label, width), + centered( + &format!( + "{:02}:{:02}", + display_remaining / 60, + display_remaining % 60 + ), + width, + ), + centered( + &format!("[{}{}]", "#".repeat(filled), "-".repeat(bar_width - filled)), + width, + ), + centered( + if completed { + "DONE" + } else if started.is_none() { + "PAUSED" + } else { + "RUNNING" + }, + width, + ), + ]; + frame.draw( + "timer", + &format!("{seconds} second countdown"), + &body, + "Space pause/resume r reset q quit", + )?; + if completed { + break; + } + let Some(key) = read_key(Duration::from_millis(100))? else { + continue; + }; + if quits(key) { + break; + } + match key.code { + KeyCode::Char(' ') => { + if let Some(instant) = started.take() { + accumulated += instant.elapsed(); + } else { + started = Some(Instant::now()); + } + } + KeyCode::Char('r' | 'R') => { + accumulated = Duration::ZERO; + started = Some(Instant::now()); + } + _ => {} + } + } + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_quoted_tables_and_rejects_unbounded_or_malformed_input() { + let rows = parse_delimited( + "name,note\nAda,\"line one\nline two\"\nLin,\"a \"\"quote\"\"\"", + ',', + ) + .expect("parse CSV"); + assert_eq!(rows.len(), 3); + assert_eq!(rows[1][1], "line one\nline two"); + assert_eq!(rows[2][1], "a \"quote\""); + assert!(parse_delimited("a,\"unterminated", ',').is_err()); + assert!(parse_delimited("a,\"closed\"junk", ',').is_err()); + } + + #[test] + fn validates_mode_specific_options() { + let timer = parse_options( + [ + "snapshot", + "timer", + "--seconds", + "60", + "--elapsed", + "5", + "--json", + ] + .into_iter() + .map(String::from) + .collect(), + false, + ) + .expect("parse timer"); + assert_eq!(timer.seconds, 60); + assert_eq!(timer.elapsed, 5); + assert!( + parse_options( + vec!["read".into(), "file".into(), "--page".into(), "2".into()], + false + ) + .is_err() + ); + assert!( + parse_options(vec!["timer".into(), "--seconds".into(), "0".into()], false).is_err() + ); + assert!( + parse_options( + vec![ + "open".into(), + "read".into(), + "file".into(), + "--limit".into(), + "2".into(), + ], + true, + ) + .is_err() + ); + assert!(slide_index(3, 2).is_err()); + assert_eq!(display_seconds(Duration::ZERO), 0); + assert_eq!(display_seconds(Duration::from_millis(1)), 1); + } + + #[test] + fn sanitizes_terminal_metadata() { + assert_eq!( + human_text("notes\x1b]0;owned\x07.md"), + "notes\\u{1b}]0;owned\\u{7}.md" + ); + } + + #[test] + fn paint_save_is_complete_and_never_overwrites() { + let directory = std::env::temp_dir().join(format!( + "keys-tools-apps-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos() + )); + fs::create_dir(&directory).expect("create paint test directory"); + let output = directory.join("drawing.txt"); + let mut paint = load_paint(None, 3, 1).expect("new paint canvas"); + paint.cells.copy_from_slice(&['A', 'B', ' ']); + + save_paint(&paint, &output).expect("save paint"); + assert_eq!(fs::read_to_string(&output).expect("read paint"), "AB\n"); + assert!(save_paint(&paint, &output).is_err()); + assert_eq!(fs::read_to_string(&output).expect("reread paint"), "AB\n"); + + fs::remove_file(output).expect("remove paint output"); + fs::remove_dir(directory).expect("remove paint test directory"); + } +} diff --git a/src/bin/apps.rs b/src/bin/apps.rs new file mode 100644 index 0000000..68f67d0 --- /dev/null +++ b/src/bin/apps.rs @@ -0,0 +1,9 @@ +fn main() { + match keys_tools::apps::run(std::env::args().skip(1).collect()) { + Ok(code) => std::process::exit(code), + Err(error) => { + eprintln!("apps: {error}"); + std::process::exit(2); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index b915fb3..e9b58ec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod apps; pub mod fetch; pub mod games; pub mod lazybox; diff --git a/tests/cli.rs b/tests/cli.rs index e685d93..419ca75 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,7 +1,9 @@ -use std::process::Command; +use std::io::Write as _; +use std::process::{Command, Output, Stdio}; fn command(binary: &str) -> Command { let path = match binary { + "apps" => env!("CARGO_BIN_EXE_apps"), "fetch" => env!("CARGO_BIN_EXE_fetch"), "games" => env!("CARGO_BIN_EXE_games"), "lazybox" => env!("CARGO_BIN_EXE_lazybox"), @@ -14,6 +16,22 @@ fn command(binary: &str) -> Command { command } +fn command_with_stdin(arguments: &[&str], input: &str) -> Output { + let mut child = command("apps") + .args(arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("start apps"); + child + .stdin + .take() + .expect("apps stdin") + .write_all(input.as_bytes()) + .expect("write apps input"); + child.wait_with_output().expect("finish apps") +} + #[test] fn fetch_supports_plain_json_and_invalid_input() { let plain = command("fetch").arg("--plain").output().expect("run fetch"); @@ -160,3 +178,71 @@ fn screensaver_snapshots_owned_scenes_and_validates_inputs() { assert_eq!(interactive.status.code(), Some(2)); assert!(String::from_utf8_lossy(&interactive.stderr).contains("need a terminal")); } + +#[test] +fn apps_runs_owned_local_workflows_and_validates_inputs() { + let read = command_with_stdin( + &["snapshot", "read", "-", "--find", "needle", "--json"], + "first\nNeedle here\nlast\n", + ); + assert!(read.status.success()); + assert!(String::from_utf8_lossy(&read.stdout).contains("\"matchCount\":1")); + + let table = command_with_stdin( + &["snapshot", "table", "-", "--delimiter", "comma", "--json"], + "name,note\nAda,\"line one\nline two\"\nLin,done\n", + ); + assert!(table.status.success()); + let output = String::from_utf8_lossy(&table.stdout); + assert!(output.contains("\"rowCount\":2")); + assert!(output.contains("line one\\nline two")); + + let plain_table = command_with_stdin( + &["snapshot", "table", "-", "--delimiter", "comma", "--plain"], + "name,note\nAda,\"line one\rline two\"\n", + ); + assert!(plain_table.status.success()); + let output = String::from_utf8_lossy(&plain_table.stdout); + assert!(output.contains("line one\\rline two")); + assert!(!output.contains('\r')); + + let slides = command_with_stdin( + &["snapshot", "slides", "-", "--page", "2", "--json"], + "# One\n---\n# Two\nbody\n", + ); + assert!(slides.status.success()); + assert!(String::from_utf8_lossy(&slides.stdout).contains("\"page\":2")); + + for arguments in [ + [ + "snapshot", "paint", "--width", "5", "--height", "2", "--json", + ] + .as_slice(), + [ + "snapshot", + "timer", + "--seconds", + "60", + "--elapsed", + "5", + "--json", + ] + .as_slice(), + ] { + assert!( + command("apps") + .args(arguments) + .output() + .expect("snapshot app") + .status + .success() + ); + } + + let interactive = command("apps") + .args(["open", "timer", "--seconds", "1"]) + .output() + .expect("reject non-terminal app"); + assert_eq!(interactive.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&interactive.stderr).contains("need a terminal")); +} From 9000a1eafa8a2f5d3a177ac3273f879e9b0a9568 Mon Sep 17 00:00:00 2001 From: Keys <70819367+keys-i@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:41:18 +1000 Subject: [PATCH 2/2] fix: sanitize terminal metadata --- src/apps.rs | 14 +------------- src/fetch.rs | 16 +++++++++------- src/lib.rs | 13 +++++++++++++ src/screensaver.rs | 22 +++++++++++++--------- tests/cli.rs | 19 +++++++++++++++++++ 5 files changed, 55 insertions(+), 29 deletions(-) diff --git a/src/apps.rs b/src/apps.rs index 45991dc..918c1aa 100644 --- a/src/apps.rs +++ b/src/apps.rs @@ -10,7 +10,7 @@ use crossterm::style::Print; use crossterm::terminal::{BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate, size}; use crate::terminal::Session; -use crate::{VERSION, json_string}; +use crate::{VERSION, human_text, json_string}; const HELP: &str = "Fast native local terminal apps\n\nUsage:\n apps [open|snapshot] read FILE [--find TEXT] [--limit N]\n apps [open|snapshot] table FILE [--delimiter comma|tab] [--limit N]\n apps [open|snapshot] slides FILE [--page N]\n apps [open|snapshot] paint [FILE] [--width N --height N] [--output FILE]\n apps [open|snapshot] timer --seconds N [--elapsed N] [--label TEXT]\n\nOptions:\n --find TEXT Find text in read mode\n --delimiter NAME comma or tab; inferred from .tsv otherwise\n --limit N Snapshot rows or lines (1..1000, default 20)\n --page N Slide number, starting at 1\n --width N New paint canvas width (1..240, default 40)\n --height N New paint canvas height (1..80, default 16)\n --output FILE New paint file; existing files are never overwritten\n --seconds N Timer duration (1..86400)\n --elapsed N Deterministic snapshot progress in seconds\n --label TEXT Timer label\n --plain Stable snapshot text\n --json Machine-readable snapshot\n -h, --help Show this help\n -V, --version Show the version\n\nControls: q quits; arrows navigate; PageUp/PageDown jump; Home/End move to edges. Slides use Left/Right. Paint uses a printable key or Space to draw, Backspace to erase, and s to save. Timer uses Space to pause and r to reset."; @@ -338,18 +338,6 @@ fn write_stdout(value: &str) -> Result { Ok(0) } -fn human_text(value: &str) -> String { - let mut output = String::with_capacity(value.len()); - for character in value.chars() { - if character.is_control() { - output.extend(character.escape_default()); - } else { - output.push(character); - } - } - output -} - fn human_path(path: &Path) -> String { human_text(&path.to_string_lossy()) } diff --git a/src/fetch.rs b/src/fetch.rs index 0583ba2..7b39f9c 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -2,7 +2,7 @@ use std::env; #[cfg(target_os = "linux")] use std::fs; -use crate::{VERSION, human_bytes, json_string, use_color}; +use crate::{VERSION, human_bytes, human_text, json_string, use_color}; const HELP: &str = "Fast system and CPU overview\n\nUsage: fetch [--plain | --json]\n\nOptions:\n --plain Stable text without decoration\n --json Machine-readable JSON\n -h, --help Show this help\n -V, --version Show the version"; @@ -111,7 +111,7 @@ fn fields(snapshot: &Snapshot) -> Vec<(&'static str, String)> { fn print_plain(snapshot: &Snapshot) { for (name, value) in fields(snapshot) { - println!("{name}\t{value}"); + println!("{name}\t{}", human_text(&value)); } } @@ -123,12 +123,14 @@ fn print_styled(snapshot: &Snapshot) { let user = env::var("USER") .or_else(|_| env::var("USERNAME")) .unwrap_or_else(|_| "user".into()); - println!( - "{CYAN}╭─ ◈ FETCH {MUTED}//{RESET} {MAGENTA}{user}@{}{RESET}", - snapshot.host - ); + let user = human_text(&user); + let host = human_text(&snapshot.host); + println!("{CYAN}╭─ ◈ FETCH {MUTED}//{RESET} {MAGENTA}{user}@{host}{RESET}"); for (name, value) in fields(snapshot) { - println!("{CYAN}│{RESET} {MUTED}{name:8}{RESET} {value}"); + println!( + "{CYAN}│{RESET} {MUTED}{name:8}{RESET} {}", + human_text(&value) + ); } println!("{CYAN}╰─{RESET}"); } diff --git a/src/lib.rs b/src/lib.rs index e9b58ec..38422e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,6 +35,18 @@ pub fn json_string(value: &str) -> String { escaped } +pub fn human_text(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + for character in value.chars() { + if character.is_control() { + output.extend(character.escape_default()); + } else { + output.push(character); + } + } + output +} + pub fn human_bytes(bytes: u64) -> String { const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; let mut value = bytes as f64; @@ -57,6 +69,7 @@ mod tests { #[test] fn escapes_json_and_formats_bytes() { assert_eq!(json_string("a\n\"b"), "\"a\\n\\\"b\""); + assert_eq!(human_text("a\x1b\x07b"), "a\\u{1b}\\u{7}b"); assert_eq!(human_bytes(512), "512 B"); assert_eq!(human_bytes(3 * 1024 * 1024 * 1024), "3.0 GiB"); } diff --git a/src/screensaver.rs b/src/screensaver.rs index 5eff6a3..04a8b3d 100644 --- a/src/screensaver.rs +++ b/src/screensaver.rs @@ -12,7 +12,7 @@ use crossterm::terminal::{BeginSynchronizedUpdate, Clear, ClearType, EndSynchron use crate::games::Rng; use crate::lazybox::{clean, command_output}; use crate::terminal::Session; -use crate::{VERSION, json_string}; +use crate::{VERSION, human_text, json_string}; const HELP: &str = "Native terminal scenes and local media playback\n\nUsage:\n screensaver\n screensaver open [mix|rain|pipes|pond|weather|git|gif] [INPUT] [OPTIONS]\n screensaver snapshot [mix|rain|pipes|pond|weather|git|gif] [INPUT] [OPTIONS]\n\nInputs:\n git [PATH] Read at most 64 commits from a local Git repository\n gif FILE Decode a bounded local GIF87a or GIF89a file\n\nOptions:\n --seed NUMBER Reproduce generated scenes\n --frame NUMBER Snapshot a later animation frame (0..10000)\n --condition NAME clear, rain, snow, storm, or fog\n --night Render the weather scene at night\n --fps NUMBER Interactive animation rate (1..60)\n --plain Stable snapshot text without decoration\n --json Machine-readable snapshot and rendered lines\n -h, --help Show this help\n -V, --version Show the version\n\nControls: q or Esc quits; Space pauses; r resets; n changes the mixed scene; Left/Right moves through Git or GIF frames; Up/Down changes speed."; @@ -25,6 +25,10 @@ const MAX_GIF_FRAMES: usize = 512; const MAX_GIF_RENDER_WIDTH: usize = 240; const MAX_GIF_RENDER_HEIGHT: usize = 160; +fn path_text(path: &Path) -> String { + human_text(&path.to_string_lossy()) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Action { Open, @@ -1079,11 +1083,11 @@ struct GitScene { impl GitScene { fn load(path: &Path) -> Result { if !path.exists() { - return Err(format!("Git path does not exist: {}", path.display())); + return Err(format!("Git path does not exist: {}", path_text(path))); } let path = path .canonicalize() - .map_err(|error| format!("cannot resolve Git path {}: {error}", path.display()))?; + .map_err(|error| format!("cannot resolve Git path {}: {error}", path_text(path)))?; let path_arg = path .to_str() .ok_or("Git path must be valid UTF-8")? @@ -1118,10 +1122,10 @@ impl GitScene { }) .collect::>(); if commits.is_empty() { - return Err(format!("no commits found in {}", path.display())); + return Err(format!("no commits found in {}", path_text(&path))); } Ok(Self { - path: clean(&path.display().to_string()), + path: path_text(&path), commits, index: 0, typed: 0, @@ -1252,12 +1256,12 @@ struct GifScene { impl GifScene { fn load(path: &Path) -> Result { let file = fs::File::open(path) - .map_err(|error| format!("cannot open GIF {}: {error}", path.display()))?; + .map_err(|error| format!("cannot open GIF {}: {error}", path_text(path)))?; let metadata = file .metadata() - .map_err(|error| format!("cannot inspect GIF {}: {error}", path.display()))?; + .map_err(|error| format!("cannot inspect GIF {}: {error}", path_text(path)))?; if !metadata.is_file() { - return Err(format!("GIF input is not a file: {}", path.display())); + return Err(format!("GIF input is not a file: {}", path_text(path))); } if metadata.len() > MAX_GIF_BYTES { return Err(format!( @@ -1268,7 +1272,7 @@ impl GifScene { let mut bytes = Vec::with_capacity(metadata.len() as usize + 1); file.take(MAX_GIF_BYTES + 1) .read_to_end(&mut bytes) - .map_err(|error| format!("cannot read GIF {}: {error}", path.display()))?; + .map_err(|error| format!("cannot read GIF {}: {error}", path_text(path)))?; if bytes.len() as u64 > MAX_GIF_BYTES { return Err(format!( "GIF input exceeds {} MiB", diff --git a/tests/cli.rs b/tests/cli.rs index 419ca75..5e2826a 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -43,6 +43,16 @@ fn fetch_supports_plain_json_and_invalid_input() { assert!(json.status.success()); assert!(String::from_utf8_lossy(&json.stdout).starts_with("{\"host\":")); + let hostile = command("fetch") + .arg("--plain") + .env("TERM_PROGRAM", "term\x1b]0;owned\x07") + .output() + .expect("sanitize terminal name"); + assert!(hostile.status.success()); + let output = String::from_utf8_lossy(&hostile.stdout); + assert!(output.contains("term\\u{1b}]0;owned\\u{7}")); + assert!(!output.contains('\x1b') && !output.contains('\x07')); + let invalid = command("fetch").arg("--wat").output().expect("run fetch"); assert_eq!(invalid.status.code(), Some(2)); assert!(String::from_utf8_lossy(&invalid.stderr).contains("unknown option")); @@ -171,6 +181,15 @@ fn screensaver_snapshots_owned_scenes_and_validates_inputs() { assert_eq!(missing.status.code(), Some(2)); assert!(String::from_utf8_lossy(&missing.stderr).contains("local GIF file")); + let hostile = command("screensaver") + .args(["snapshot", "gif", "missing\x1b]0;owned\x07.gif"]) + .output() + .expect("sanitize GIF path"); + assert_eq!(hostile.status.code(), Some(2)); + let error = String::from_utf8_lossy(&hostile.stderr); + assert!(error.contains("missing\\u{1b}]0;owned\\u{7}.gif")); + assert!(!error.contains('\x1b') && !error.contains('\x07')); + let interactive = command("screensaver") .args(["open", "pond"]) .output()