diff --git a/- b/- new file mode 100644 index 0000000..046e5a0 Binary files /dev/null and b/- differ diff --git a/.claude/skills/termlens/SKILL.md b/.claude/skills/termlens/SKILL.md new file mode 100644 index 0000000..311e12a --- /dev/null +++ b/.claude/skills/termlens/SKILL.md @@ -0,0 +1,452 @@ +--- +name: termlens +description: Write, fix or review headless terminal tests for a Rust CLI or TUI (ratatui, crossterm, cursive, plain println) with the termlens crate — spawn the real binary in a PTY, wait on the rendered screen without sleeping, snapshot it with insta, assert on cells and styles. Use whenever a test spawns a terminal program, whenever a terminal test is flaky, sleeps or times out, and whenever someone asks how to test a TUI end to end. +--- + +# Testing terminal programs with termlens + +Written against **termlens 0.9.0**. Every `rust` block below is a complete +integration test that is compiled against the crate in CI, so the API it +shows is the API that exists. The recipes spawn a binary called `myapp` +that draws a list with a `> ` highlight, a status line ending in +`Ready: j/k move, q quits`, and prints usage on `--help`; substitute your +application's own texts where the comments say so. + +## 1. What termlens is, and when to reach for it + +termlens spawns your **real binary** in a **real pseudo-terminal**, drains +its output on a reader thread through a VT emulator into an in-memory +**screen grid**, and lets a test wait on and assert against that grid — +Playwright for the terminal. Unix only (Linux, macOS). + +Use it for the things an in-process mock cannot see: + +- raw-mode and alternate-screen entry and exit, and whether the terminal is + left broken after a panic or a `q`; +- what the user actually sees — box drawing, colours, wide characters, + cursor position — after the bytes have been through a terminal; +- key, mouse, paste and resize handling as the terminal really encodes them + under the modes the application enabled; +- a CLI's `--help`, its exit code, its behaviour when the terminal is 40 + columns wide; +- anything printed outside the framework: a stray `println!`, a logger, a + panic message. + +Keep using `ratatui::backend::TestBackend` (or plain unit tests) for widget +layout and rendering logic: it is faster and finer-grained. termlens is the +second layer — a small number of end-to-end flows through the real binary. + +## 2. The model in sixty seconds + +```text +your binary ──PTY──▶ reader thread ──▶ VT emulator ──▶ Screen (immutable snapshot) + ▲ +your test ── send(Key) · click · paste · resize ──▶ PTY └── wait_until / snapshot_after / wait_exit +``` + +- **A `Screen` is one consistent instant.** Every accessor on it reads the + same snapshot; two `screen()` calls are two instants. +- **Every wait is deadline-bounded** (5 s by default) and **every failure + embeds the screen**, so a timeout in CI shows what the application was + displaying. There is no unbounded wait, on purpose. +- **Nothing sleeps.** The reader thread drains continuously; waits wake on + new output. This is what makes the tests fast when green and readable + when red. +- **Nothing is claimed that the emulator cannot see.** Terminal queries the + application sends (cursor position, device attributes, mode probes) are + answered truthfully or left unanswered and named in the next timeout. + +## 3. Golden rules — read these before writing a test + +1. **Never `thread::sleep`.** A sleep is either too short (flaky under CI + load) or too long (slow every run), and it hides *what* you were waiting + for. Wait on the screen instead: `wait_until(|s| …)` for a fact, + `snapshot_after(|s| …)` for a fact followed by a whole-screen snapshot, + `wait_stable(quiet)` for "the picture stopped changing". The single + sanctioned delay is `send_after(delay, key)`, which exists because `Esc` + followed immediately by another key is byte-identical to an `Alt` chord. + +2. **Snapshot only a settled screen.** `wait_until(pred)` guarantees the + bytes that made `pred` true were processed — and nothing more. A repaint + has no end marker, so the predicate can fire on a half-painted screen, + including half a row. Either wait on the **last** thing the application + paints, or use `snapshot_after`, which waits for the predicate and then + for the picture to hold still for 100 ms before handing you the screen. + +3. **One predicate per instant.** Everything you assert about one moment + goes into one closure: `wait_until(|s| s.contains("NORMAL") && + s.contains("Tasks 1/10"))`. `wait_until(a)` followed by + `assert!(screen().b)` is a race between two instants. + +4. **Spawn your own binary with `termlens::bin!("myapp")`.** It expands to + the builder chain every test otherwise repeats — `size(80, 24)`, + `env_clear()`, `timeout(5 s)`, `spawn(env!("CARGO_BIN_EXE_myapp"))` — and + a misspelled name is a **compile error**, not a spawn failure at run + time. Builder calls after the name override any default: + `bin!("myapp", size(120, 40), env("NO_COLOR", "1"))`. + +5. **Geometry is `(cols, rows)`, from 2 to 1000 per axis.** `size(0, 0)` and + `size(1, 1)` are refused with `Error::Size`: one column panics the + emulator on a double-width character and one row panics it on a line + that wraps. Grids past 1000 per axis are refused because every snapshot + costs one entry per cell. 80x24 is the default and is what you want. + +6. **Two coordinate orders exist; do not mix them.** Everything that + addresses a cell is **row-first**: `find` → `(row, col)`, `cell(row, + col)`, `row_text(row)`, `cursor()` → `(row, col, visible)`. Everything + that speaks of terminal geometry or a pointer is **column-first**: + `size()` → `(cols, rows)`, `resize(cols, rows)`, `click(col, row)`, + `scroll(col, row, …)`, `drag(button, (col, row), (col, row))`. Never + pass a `find` result straight into `drag` — the tuple types match and + the axes do not. + +7. **Always finish the process.** Send the quit key, `wait_exit()?` and + assert on the `ExitStatus` (`success()`, `code()`, `signal()`), then + assert `!t.screen().alternate_screen()` so an application that leaves + the user's terminal in the alternate screen fails the test. `Drop` kills + and reaps whatever is left, so a failing test never leaks a process. + +8. **`wait_frame` only works for applications that emit DEC 2026 + synchronized updates.** Stock ratatui 0.30 with crossterm does **not** + (measured: `repaints()` stays 0), so `wait_frame` times out against it + with a message saying exactly that. Default to `snapshot_after`. Use + `wait_frame` only if the application brackets its repaints in + `BeginSynchronizedUpdate` / `EndSynchronizedUpdate`. + +9. **Return `termlens::Result<()>` from the test and use `?`.** The + `Display` of every error carries the screen, so a failing wait prints + the grid the application was showing instead of `called unwrap() on Err`. + +10. **Snapshot the `Screen`, not its text.** `insta::assert_snapshot!(screen)` + records the header (`size: 80x24 cursor: 3,5` or `cursor: hidden`) and + the grid; `screen.with_styles()` adds a `styles:` block that catches a + colour regression. `.text()` drops the header and `format!("{:?}")` is + the same as `Display`. Review changes with `cargo insta review`; never + blind-accept with `INSTA_UPDATE=always`. + +11. **The environment is hermetic by default — set what the app reads.** + Under `env_clear()` (which `bin!` applies) the child sees only + `TERM=xterm-256color`, `SHELL=/bin/sh` and what you set with `env(…)`. + No `HOME`, no `LANG`, no `COLORTERM`, no `NO_COLOR`, no `PATH` — so a + bare program name cannot resolve (use an absolute path or `bin!`), and + an application that checks `NO_COLOR` or `COLORTERM` needs them set + explicitly for the case under test. + +12. **The grid is Unicode-aware; think in cells.** A double-width character + (CJK, most emoji) occupies two cells: the leading one `is_wide()`, the + next `is_wide_continuation()`. `find` reports real terminal columns. + `contains` and `find` fold both sides to NFC and search the **visible + screen only** — text that scrolled off is in `full_text()`, and a line + that wrapped is two rows, so a needle spanning the wrap is not found. + +## 4. Setup + +```toml +[dev-dependencies] +termlens = "0.9" +insta = "1" # for the snapshot recipes; termlens also re-exports it as `termlens::insta` +``` + +- Put the tests in `tests/` **of the package that owns the `[[bin]]`**: + Cargo sets `CARGO_BIN_EXE_` only there, and `bin!` needs it at + compile time. For a binary in a sibling crate, build it and pass the path + to `Terminal::builder().spawn(path)` instead. +- The binary is built by `cargo test` before the tests run. Tests run in + parallel by default; each spawns its own PTY, which is fine. +- Gate the test file with `#![cfg(unix)]` if the crate must also build on + Windows. +- `add --features decode` only if you assert on the pixels of inline images. + +## 5. Recipes + +### Recipe A — hermetic CLI snapshot (`myapp --help`) + +```rust +use termlens::Terminal; + +#[test] +fn help_renders_and_exits_zero() -> termlens::Result<()> { + // 80x24, cleared environment, 5 s deadline, compile-time-checked path. + let mut t = termlens::bin!("myapp", args(["--help"]))?; + + // Wait for the LAST line of the help text before waiting for exit. A + // program that prints and exits within a millisecond can, rarely and + // under load on macOS, lose its tail to PTY teardown; waiting on the + // tail first turns that into a loud timeout instead of a truncated + // snapshot that passes. + t.wait_until(|s| s.contains("q quit"))?; // your help's last line + let status = t.wait_exit()?; + assert!(status.success(), "exit status: {status}"); + + // The header records the size and cursor; the body is the grid. + insta::assert_snapshot!(t.screen()); + Ok(()) +} + +#[test] +fn a_bad_flag_is_reported_with_an_exit_code() -> termlens::Result<()> { + let mut t = Terminal::builder() + .size(80, 24) + .env_clear() + .timeout(std::time::Duration::from_secs(5)) + .args(["--definitely-not-a-flag"]) + .spawn(env!("CARGO_BIN_EXE_myapp"))?; + // stderr lands on the same screen as stdout: it is one terminal. + t.wait_until(|s| s.contains("unexpected argument"))?; + let status = t.wait_exit()?; + // Assert what your CLI promises; clap exits 2 on a usage error. + assert_eq!(status.code(), Some(2), "status: {status}"); + assert_eq!(status.signal(), None, "exited, not killed: {status}"); + Ok(()) +} +``` + +### Recipe B — interactive ratatui navigation and keystrokes + +```rust +use termlens::Key; + +#[test] +fn moving_the_highlight_and_quitting_cleanly() -> termlens::Result<()> { + let mut t = termlens::bin!("myapp")?; + + // Predicate, then a 100 ms settle, then the screen: the safe sequence + // for a whole-screen snapshot. Name the last thing the app paints. + let first = t.snapshot_after(|s| s.contains("Ready"))?; + assert!(first.alternate_screen(), "a TUI should be on the alternate screen:\n{first}"); + assert!(first.contains("> Alpha"), "{first}"); + insta::assert_snapshot!("initial_frame", first); + + // Send a key, then wait on what the key CHANGES — not on text that was + // already true before the key, or the wait returns the old screen. + t.send(Key::Char('j'))?; + let moved = t.snapshot_after(|s| s.contains("> Beta"))?; + assert!(!moved.contains("> Alpha"), "{moved}"); + + // Arrow keys and chords encode as the terminal would (DECCKM-aware). + t.send(Key::Down)?; + t.wait_until(|s| s.contains("> Gamma"))?; + t.send(Key::Up)?; + t.wait_until(|s| s.contains("> Beta"))?; + + // Finish: quit, assert the exit, and assert the terminal was restored. + t.send(Key::Char('q'))?; + let status = t.wait_exit()?; + assert!(status.success(), "status: {status}"); + assert!(!t.screen().alternate_screen(), "the app left the terminal in the alternate screen"); + Ok(()) +} +``` + +If the flow needs `Esc` followed by another key, use the one sanctioned +delay — `t.send_after(Duration::from_millis(20), Key::Char('j'))?` — so the +application's read boundary falls between the two writes and it sees two +presses rather than one `Alt-j` chord. + +### Recipe C — overriding the defaults (size, environment, deadline) + +```rust +use std::time::Duration; +use termlens::Color; + +#[test] +fn honours_no_color_at_a_custom_size() -> termlens::Result<()> { + // Builder calls after the name override bin!'s defaults; the rest stay. + let mut t = termlens::bin!( + "myapp", + size(100, 30), // (cols, rows) + env("NO_COLOR", "1"), // the app reads this at startup + timeout(Duration::from_secs(10)), // every wait's default deadline + )?; + let s = t.snapshot_after(|s| s.contains("Ready"))?; + + assert_eq!(s.size(), (100, 30), "{s}"); + + // With NO_COLOR the title is drawn in the default colour, not cyan. + let (row, col) = s.find("myapp").expect("title is on screen"); + let title = s.cell(row, col).expect("in range"); + assert_eq!(title.style().fg, Color::Default, "{}", s.with_styles()); + assert!(!title.style().bold); + + // One slow step gets its own deadline instead of a slower suite. + t.wait_until_for(|s| s.contains("Ready"), Duration::from_secs(30))?; + Ok(()) +} +``` + +### Recipe D — targeted screen and style assertions + +```rust +use termlens::Color; + +#[test] +fn cells_styles_and_wide_characters() -> termlens::Result<()> { + let mut t = termlens::bin!("myapp")?; + let s = t.snapshot_after(|s| s.contains("Ready"))?; + + // Text: visible screen, NFC-folded, trailing padding never matched. + assert!(s.contains("Alpha") && s.contains("Beta"), "{s}"); + assert_eq!(s.find("Ready"), Some((23, 0)), "status line sits on the last row: {s}"); + + // Cells and styles: the highlighted row is drawn in reverse video. + let (row, col) = s.find("> Alpha").expect("highlight"); + let cell = s.cell(row, col).expect("in range"); + assert!(cell.style().reverse, "{}", s.with_styles()); + // A coloured, bold title: ratatui's Cyan is ANSI colour 6. + let (trow, tcol) = s.find("myapp").expect("title"); + let title = s.cell(trow, tcol).unwrap().style(); + assert_eq!((title.fg, title.bold), (Color::Indexed(6), true)); + + // Find a cell by a property of the cell rather than by its text. + assert_eq!(s.find_by(|c| c.style().reverse), Some((row, col))); + + // Wide characters: one glyph, two cells, real columns reported. + let (crow, ccol) = s.find("東京").expect("CJK item"); + assert!(s.cell(crow, ccol).unwrap().is_wide()); + assert!(s.cell(crow, ccol + 1).unwrap().is_wide_continuation()); + assert_eq!(s.row_text(crow).trim_matches(['│', ' ']), "東京"); + + // Regions and the cursor. rect_text is (cols, rows), like size(). + let list_pane = s.rect_text(0..20, 0..6); + assert!(list_pane.contains("Gamma"), "{list_pane}"); + let (_, _, visible) = s.cursor(); + assert!(!visible, "a list view hides the cursor: {s}"); + + t.send(termlens::Key::Char('q'))?; + assert!(t.wait_exit()?.success()); + Ok(()) +} +``` + +## 6. Reading a failure + +Every error's `Display` ends with the screen, under a header that says +which screen it is (`--- screen at timeout ---`, `--- final screen ---`). +Read the first line for the cause: + +| First line says | Meaning | Do | +|---|---|---| +| `timed out after 5s while waiting for the screen predicate to hold` | the predicate never became true | look at the embedded grid; the text is usually spelled differently, on another row, or scrolled off (the note says how many rows scrolled) | +| `… note: N rows have scrolled off the top` | the text went into history | assert with `full_text()` / `scrollback_text()` | +| `… note: the application queried the terminal (^[[?u …) and received no answer` | the app is blocked on a probe termlens deliberately does not answer | the app needs a fallback; see the termlens README's Known limitations | +| `terminal closed (EOF) while waiting for …` | the app exited before the predicate held | check `wait_exit()` first, or the app crashed — the final screen shows why | +| `the application never emitted a DEC 2026 synchronized update` | `wait_frame` against an app without synchronized output | use `snapshot_after` / `wait_until` (rule 8) | +| `input not receivable: the application has not enabled mouse tracking` | `click`/`drag`/`scroll` before the app enabled the mouse | `wait_until(|s| s.mouse_mode() != MouseMode::None)` first | +| `input not receivable: mouse at (50, 2) is outside the 20x5 grid` | coordinates swapped or out of range | rule 6 | +| `failed to spawn \`sh\`: \`sh\` is a bare program name and env_clear() removed PATH` | bare program name under `env_clear` | absolute path, `bin!`, or `.env("PATH", …)` | +| `invalid terminal size: a terminal needs at least 2 columns and 2 rows` | geometry below 2x2 (past 1000 has its own message) | rule 5 | +| `the terminal emulator failed and the screen stopped advancing` (`Error::Emulator`) | a bug in the emulation, not in your app | report it to termlens with the detail it names | + +## 7. API cheat sheet + +**Spawn** — `termlens::bin!("name" $(, method(args))*)` or +`Terminal::builder()`: + +| Builder method | Meaning | +|---|---| +| `.size(cols, rows)` | 2..=1000 each; default 80x24 | +| `.timeout(Duration)` | default deadline for every wait (5 s) | +| `.arg(a)` / `.args([..])` | program arguments | +| `.env(k, v)` / `.envs([..])` / `.env_clear()` | environment; `env_clear` keeps `TERM` and `SHELL` pinned and drops the rest | +| `.current_dir(path)` | default: the test process's directory | +| `.scrollback(rows)` | history retained (default 1000, text only) | +| `.spawn(program) -> Result` | program is a path or a name on `PATH` | + +**Wait** (all return `termlens::Result`, all embed the screen on failure, all have a `_for(…, timeout)` twin): + +| Method | Returns | Use for | +|---|---|---| +| `wait_until(\|s\| bool)` | `()` | a fact about the screen | +| `snapshot_after(\|s\| bool)` | `Screen` | a fact, then a settled whole-screen snapshot | +| `wait_stable(quiet)` | `Screen` | the picture unchanged for `quiet`; bells and no-op repaints do not reset it | +| `wait_idle(quiet)` | `()` | no *bytes* for `quiet` — a weaker, older sibling of `wait_stable` | +| `wait_frame(\|s\| bool)` | `Screen` | complete DEC 2026 frames only (rule 8) | +| `wait_exit()` | `ExitStatus` | the child's exit; `success()`, `code() -> Option`, `signal() -> Option<&str>` | + +**Drive**: `send(Key)`, `send_str("text")` (no Enter — send `Key::Enter` +yourself; `"\n"` would send LF, not CR), `paste("text")` (bracketed if the +app enabled it), `send_after(delay, Key)`, `click(col, row)`, +`click_with(MouseButton::Right, col, row)`, `drag(MouseButton::Left, (c, r), +(c, r))`, `scroll(col, row, Scroll::Down)`, `resize(cols, rows)`, +`focus_in()` / `focus_out()`, `signal(Signal::Term)` (Unix), `pid()`. + +**Keys**: `Key::Char('j')`, `Enter`, `Esc`, `Tab`, `BackTab`, `Backspace`, +`Delete`, `Insert`, `Up`/`Down`/`Left`/`Right`, `Home`/`End`, +`PageUp`/`PageDown`, `F(1..=12)`, `Ctrl('c')`, `Alt('x')`; chords on any key: +`Key::Right.ctrl()`, `Key::F(5).ctrl().shift()`. + +**Screen** (immutable; every accessor reads one instant): + +| Accessor | Returns | +|---|---| +| `contains(&str)` / `find(&str)` | `bool` / `Option<(row, col)>` — visible grid, NFC-folded | +| `find_by(\|&Cell\| bool)` | `Option<(row, col)>` | +| `cell(row, col)` | `Option<&Cell>`: `contents()`, `style()`, `is_wide()`, `is_wide_continuation()` | +| `row_text(row)` / `text()` / `rect_text(cols, rows)` | `String` | +| `full_text()` / `scrollback_text()` / `scrollback_rows()` | history + screen / history / count | +| `size()` / `cols()` / `rows()` | `(cols, rows)` | +| `cursor()` | `(row, col, visible)`; `cursor_shape()`, `cursor_blink()` | +| `alternate_screen()`, `bracketed_paste()`, `application_cursor()`, `focus_events()` | mode flags | +| `mouse_mode()` / `mouse_modes()` | reporting protocol / the set the app enabled | +| `title()`, `clipboard()`, `links()`, `bells()`, `repaints()`, `graphics()` | out-of-band state | +| `with_styles()` | `Display` with a `styles:` block; snapshot this to catch colour regressions | + +**Style** (`Copy`, public fields): `fg`, `bg` (`Color::Default` / +`Color::Indexed(u8)` / `Color::Rgb(u8, u8, u8)`), `bold`, `dim`, `italic`, +`underline`, `reverse`, `blink`, `conceal`, `strikethrough`. Overline and +double underline are not modelled. + +**Errors** (`termlens::Error`, `#[non_exhaustive]`): `Timeout { waiting_for, +timeout, screen }`, `Eof { waiting_for, screen }`, `Spawn { command, reason }`, +`Size(String)`, `Input(String)`, `Write { what, screen }`, `Emulator { +detail, screen }`, `Pty(String)`, `Io(std::io::Error)`. `err.screen()` returns +the embedded screen when there is one. + +## 8. Pitfalls an agent falls into, and the fix + +| You are about to write | Write instead | +|---|---| +| `std::thread::sleep(Duration::from_millis(500)); let s = t.screen();` | `let s = t.snapshot_after(\|s\| s.contains("…"))?;` | +| `t.wait_until(\|s\| s.contains("title"))?; insta::assert_snapshot!(t.screen());` | `let s = t.snapshot_after(\|s\| s.contains("…last painted…"))?; insta::assert_snapshot!(s);` | +| `t.wait_until(a)?; assert!(t.screen().b);` | `t.wait_until(\|s\| a(s) && b(s))?;` | +| `.size(0, 0)` / `.size(1, 1)` | leave the 80x24 default, or `.size(cols, rows)` with both in 2..=1000 | +| `Terminal::builder().spawn("myapp")` | `termlens::bin!("myapp")?` — a name on `PATH` is not your binary, and under `env_clear` there is no `PATH` | +| `t.click(row, col)` / `t.drag(b, s.find("x").unwrap(), …)` | `t.click(col, row)`; destructure the `find` result and swap | +| `t.send_str("quit\n")` | `t.send_str("quit")?; t.send(Key::Enter)?;` | +| `t.wait_frame(…)` against a ratatui app | `t.snapshot_after(…)` unless the app emits synchronized updates | +| `t.click(3, 4)?` as the first thing after spawn | `t.wait_until(\|s\| s.mouse_mode() != MouseMode::None)?;` first | +| `.timeout(Duration::from_secs(60))` to stop a flake | find the race (rules 2 and 3); use `_for` on the one slow step | +| `insta::assert_snapshot!(t.screen().text())` | `insta::assert_snapshot!(t.screen())` or `.with_styles()` | +| `assert!(t.screen().contains("done"))` after the app printed a lot | `t.screen().full_text().contains("done")` — it scrolled | +| `.unwrap()` everywhere in a `fn test()` | `-> termlens::Result<()>` and `?`, so the failure prints the screen | +| a test that never quits the app | send the quit key, `wait_exit()?`, assert `!alternate_screen()` | +| `INSTA_UPDATE=always cargo test` | `cargo insta review`, and read every diff | + +## 9. Pairing with insta + +- `insta::assert_snapshot!(screen)` — text grid with header. Stable across + runs as long as the application draws nothing volatile. +- `insta::assert_snapshot!(screen.with_styles())` — adds `styles:` runs like + `0: 1-5 fg=6 bold`; use it where a colour or a highlight is the point. +- `insta::assert_snapshot!("name", screen)` — several snapshots in one test. +- Inline snapshots work: `insta::assert_snapshot!(screen, @"")`, then + `cargo insta review` fills the literal. +- `termlens::assert_screen_snapshot!(screen)` is the same call through the + `insta` termlens re-exports, for crates that do not want their own `insta` + dev-dependency. +- Volatile content (a clock, a PID, a spinner) breaks whole-screen + snapshots. insta's text filters are not grid-aware — a shorter replacement + shifts every column after it — so prefer asserting the stable region with + `rect_text(cols, rows)` and the volatile field with `contains`/`find`, + and snapshot the whole screen only when nothing on it moves. +- Snapshot files live in `tests/snapshots/`; commit them. Review every + change with `cargo insta review`; a diff you cannot explain is a bug. + +## 10. A checklist before you finish + +- [ ] No `sleep` anywhere; every wait names what it waits for. +- [ ] Every whole-screen snapshot comes from `snapshot_after` or a wait on the last-painted text. +- [ ] Each test quits the app, asserts the exit status, and asserts `!alternate_screen()`. +- [ ] Coordinates: `(row, col)` from `find`/`cell`, `(col, row)` into `click`/`size`. +- [ ] Environment set explicitly for anything the app reads; `bin!` used for own binaries. +- [ ] Tests return `termlens::Result<()>`; snapshots reviewed with `cargo insta review`. diff --git a/.github/scripts/extract-changelog.sh b/.github/scripts/extract-changelog.sh index f3bc4dc..66632f6 100755 --- a/.github/scripts/extract-changelog.sh +++ b/.github/scripts/extract-changelog.sh @@ -17,12 +17,27 @@ cd "$(dirname "$0")/../.." version="${1:?usage: extract-changelog.sh }" version="${version#v}" +# Did the header exist at all? Asked separately, because a section that is +# present and *empty* is a different accident from one that is absent — and +# the empty one is the likely accident, since RELEASING.md step 2 is a hand +# edit and step 2b's grep only checks version strings. Reporting both as +# "no section found" sent a reader looking for a heading that was there. +if grep -q "^## \[${version}\]" CHANGELOG.md; then + present=1 +else + present=0 +fi + out="$(awk -v ver="$version" ' # Section headers look like "## [0.1.0] - 2026-01-31" or "## [Unreleased]". /^## \[/ { if (found) exit if (index($0, "[" ver "]") > 0) { found = 1; next } } + # The link block at the foot of the file ends the last section. Without + # this the oldest section ran to EOF and absorbed every link definition: + # `extract-changelog.sh 0.1.0` printed 157 lines ending in a URL. + found && /^\[.*\]:/ { exit } found { lines[++n] = $0 } END { start = 1; while (start <= n && lines[start] ~ /^[[:space:]]*$/) start++ @@ -32,7 +47,11 @@ out="$(awk -v ver="$version" ' ' CHANGELOG.md)" if [ -z "$out" ]; then - echo "::error::No CHANGELOG.md section found for version '${version}'." >&2 + if [ "$present" -eq 1 ]; then + echo "::error::CHANGELOG.md has a '${version}' section but it is empty — write the notes before tagging." >&2 + else + echo "::error::No CHANGELOG.md section found for version '${version}'." >&2 + fi exit 1 fi printf '%s\n' "$out" diff --git a/.github/scripts/test-extract-changelog.sh b/.github/scripts/test-extract-changelog.sh new file mode 100755 index 0000000..5e64829 --- /dev/null +++ b/.github/scripts/test-extract-changelog.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Test extract-changelog.sh against a fixture changelog. +# +# This is the only piece of the release path with no test at all, which is +# why three behaviours could sit in it unnoticed: a section present and empty +# reported as absent, the oldest section absorbing the link block, and the +# runbook attributing the failure to a job that never opened CHANGELOG.md. +# +# Portable shell: this runs on the macOS leg too, where bash is 3.2. +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +SCRIPT="$ROOT/.github/scripts/extract-changelog.sh" +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +# A fixture with every shape: an empty [Unreleased], a filled release, an +# empty one, the oldest, and the link block that used to be swallowed. +mkdir -p "$WORK/.github/scripts" +cp "$SCRIPT" "$WORK/.github/scripts/" +cat > "$WORK/CHANGELOG.md" <<'CHANGELOG' +# Changelog + +## [Unreleased] + +## [0.7.0] - 2026-09-15 + +### Added + +- something real. + +## [0.6.9] - 2026-09-01 + +## [0.1.0] - 2026-01-31 + +### Added + +- the first one. + +[0.7.0]: https://example.invalid/0.7.0 +[0.1.0]: https://example.invalid/0.1.0 +CHANGELOG + +run() { ( cd "$WORK" && ./.github/scripts/extract-changelog.sh "$@" ) 2>&1; } +status=0 +fail() { echo "FAIL: $1" >&2; status=1; } + +# A filled section, by bare version and by tag. +for version in 0.7.0 v0.7.0; do + out=$(run "$version") || { fail "$version should succeed"; continue; } + case "$out" in + *"something real"*) ;; + *) fail "$version: notes missing: $out" ;; + esac +done + +# Present but empty is its own message, and is not "not found". +for version in Unreleased 0.6.9; do + if out=$(run "$version"); then + fail "$version: an empty section must fail" + else + case "$out" in + *"but it is empty"*) ;; + *) fail "$version: must say the section is empty, got: $out" ;; + esac + fi +done + +# Absent is the other message. +if out=$(run 9.9.9); then + fail "9.9.9: an absent section must fail" +else + case "$out" in + *"No CHANGELOG.md section found"*) ;; + *) fail "9.9.9: wrong message: $out" ;; + esac +fi + +# The oldest section stops at the link block rather than running to EOF. +out=$(run 0.1.0) +case "$out" in + *"example.invalid"*) fail "the oldest section absorbed the link block: $out" ;; + *"the first one"*) ;; + *) fail "0.1.0: notes missing: $out" ;; +esac + +[ "$status" -eq 0 ] && echo "extract-changelog.sh: every shape behaves" +exit "$status" diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml index 5ac4c4d..d27ed92 100644 --- a/.github/workflows/binaries.yml +++ b/.github/workflows/binaries.yml @@ -9,12 +9,33 @@ name: binaries # -- and when it does the fix must not be "cut another version". This workflow # is re-runnable against any existing tag. # -# It fires when a release is published, and by hand for a tag whose artifacts -# need rebuilding. +# It is called by release.yml once the GitHub release exists, and can be run +# by hand for a tag whose artifacts need rebuilding. +# +# NOT `on: release`. GitHub does not start workflow runs from events raised +# by `GITHUB_TOKEN` — a deliberate anti-recursion rule — and release.yml +# creates the release with exactly that token. So this trigger could never +# fire, and never did: every run of this workflow through 0.6.3 was a manual +# dispatch. Between the tag and somebody remembering, the release had no +# binaries for any platform and `brew install` still served the previous +# version. `workflow_call` makes the artifacts part of the release rather +# than a thing that happens near it. on: - release: - types: [published] + workflow_call: + inputs: + tag: + description: the release tag to build binaries for, e.g. v0.6.2 + required: true + type: string + secrets: + HOMEBREW_TAP_TOKEN: + description: >- + PAT with contents:write on the tap repository. Named rather than + inherited: a called workflow with `secrets: inherit` gets every + secret this repository has, and this one needs exactly one. Absent + is fine — the formula job says so and the tap keeps what it has. + required: false workflow_dispatch: inputs: tag: @@ -29,11 +50,11 @@ permissions: # interrupted between building and uploading leaves the release short an # archive, which is the failure this workflow exists to make recoverable. concurrency: - group: binaries-${{ inputs.tag || github.event.release.tag_name }} + group: binaries-${{ inputs.tag }} cancel-in-progress: false env: - TAG: ${{ inputs.tag || github.event.release.tag_name }} + TAG: ${{ inputs.tag }} jobs: # Prebuilt binaries, so that using mossaic does not require a Rust toolchain. @@ -293,7 +314,7 @@ jobs: if: steps.token.outputs.usable == 'true' env: GH_TOKEN: ${{ github.token }} - TAG: ${{ inputs.tag || github.event.release.tag_name }} + TAG: ${{ inputs.tag }} run: | set -euo pipefail version="${TAG#v}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f364bd..86bd20c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,6 +141,20 @@ jobs: # Workflow security audit (template injection, credential persistence, # unpinned actions, …). Accepted findings live in .github/zizmor.yml. + # The release path's one untested script. Three behaviours sat in it + # unnoticed — an empty section reported as absent, the oldest section + # absorbing the link block — and the empty case would have published the + # crate and *then* failed the release, leaving a version permanently on + # crates.io with no GitHub release and no platform archives. + release-scripts: + name: release scripts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - run: ./.github/scripts/test-extract-changelog.sh + zizmor: name: zizmor runs-on: ubuntu-latest @@ -154,14 +168,21 @@ jobs: # Version pinned; bump deliberately alongside a fresh local audit. # The composite action is audited too: it pulls actions of its own, # and it is the piece other people run in their repositories. - run: pipx run zizmor==1.29.0 --persona=pedantic .github/workflows/ action/action.yml + # `track.example.yml` is audited too, and is the file that most needs + # it: its first line tells you to copy it into a public repository of + # your own. It is not under `.github/workflows/`, so neither the glob + # nor GitHub itself ever parsed it — CI was green and the one file + # the gate existed for was the one it was not pointed at. + run: >- + pipx run zizmor==1.29.0 --persona=pedantic + .github/workflows/ action/action.yml action/track.example.yml # Single stable job name for branch protection: require this one check and # matrix/job changes never break the required-checks configuration. required-green: name: required-green if: always() - needs: [fmt, clippy, test, windows, msrv, docs, deny, zizmor] + needs: [fmt, clippy, test, windows, msrv, docs, deny, zizmor, release-scripts] runs-on: ubuntu-latest steps: - name: Verify every needed job succeeded diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml index db5ff85..b036dd2 100644 --- a/.github/workflows/install.yml +++ b/.github/workflows/install.yml @@ -20,9 +20,16 @@ # because the registry is what a user gets. name: install +# NOT `on: release` — see the note in binaries.yml: a release created with +# `GITHUB_TOKEN` raises no event that can start a workflow, so this trigger +# never fired and nothing verified that a published crate installs. on: - release: - types: [published] + workflow_call: + inputs: + version: + description: "Version to verify (default: the newest published)" + required: false + type: string workflow_dispatch: inputs: version: @@ -36,7 +43,7 @@ permissions: contents: read concurrency: - group: install-${{ github.event.release.tag_name || inputs.version || 'latest' }} + group: install-${{ inputs.version || 'latest' }} cancel-in-progress: false jobs: @@ -63,12 +70,14 @@ jobs: - name: Ask the registry what was published id: crate env: - TAG: ${{ github.event.release.tag_name }} + # One source now: `inputs.version` covers the dispatch and the call + # from release.yml alike. The `release` event this used to read + # could never fire here. WANTED: ${{ inputs.version }} run: | api="https://crates.io/api/v1/crates/mossaic" agent="mossaic-install-check (github actions)" - version="${WANTED:-${TAG#v}}" + version="${WANTED#v}" if [ -z "$version" ]; then version=$(curl -sSf -H "User-Agent: $agent" "$api" | jq -r .crate.max_stable_version) fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a989c29..5f21b77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,18 @@ jobs: echo "::error::Tag v${tag} does not match the crate version ${version}. Bump the version (docs/RELEASING.md) before tagging." exit 1 fi + # Before `cargo publish`, which is the irreversible step. The + # extraction used to run only in `github-release`, which `needs: + # publish` — so a tag whose CHANGELOG section was present and empty + # put the version permanently on crates.io and *then* died on a + # message saying the section was missing. Recovery is not re-tagging + # (RELEASING.md forbids it); it is creating the release by hand. + # + # `ci`, `semver` and `publish` all descend from this job, so nothing + # publishes until the notes exist — which is what the runbook already + # implied happened. + - name: The CHANGELOG has notes for this version + run: .github/scripts/extract-changelog.sh "$GITHUB_REF_NAME" > /dev/null # Re-run the exact CI gates (fmt, clippy, test matrix, msrv, docs, deny). ci: @@ -105,6 +117,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false + # Re-extracted rather than carried through an artifact: the script is + # deterministic over a file this job checks out, and `verify-version` + # has already proved it succeeds. Passing it as an artifact would mean + # two more third-party actions for a `cat`. - name: Extract notes from CHANGELOG.md run: .github/scripts/extract-changelog.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/notes.md" - name: Create the release @@ -116,6 +132,31 @@ jobs: --notes-file "$RUNNER_TEMP/notes.md" \ --verify-tag + # The platform archives and the tap formula, and the check that what was + # published actually installs. Called rather than triggered: a release + # created with `GITHUB_TOKEN` raises no event that can start a workflow, so + # both of these sat on an `on: release` trigger that had never once fired. + # Every run of either through 0.6.3 was a manual dispatch, and between the + # tag and somebody remembering, the release had no binaries and + # `brew install` served the previous version. + binaries: + name: binaries + needs: github-release + uses: ./.github/workflows/binaries.yml + permissions: + contents: write # upload the archives to the release + secrets: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + with: + tag: ${{ github.ref_name }} + + install: + name: install + needs: binaries + uses: ./.github/workflows/install.yml + with: + version: ${{ github.ref_name }} + # Tell the repository that tests this tool against a real subject that a # new version exists, so the deep run happens now rather than at its next # scheduled tick. diff --git a/.github/zizmor.yml b/.github/zizmor.yml index f7a2a27..cb3dd8d 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -14,3 +14,10 @@ rules: - release.yml - install.yml + # Pinning this project's own action by tag is documented style: the tag is + # the release knob (`version:` in action/README.md), and a reader copying + # the example wants a version they can recognise, not a SHA. Third-party + # actions in the same file are SHA-pinned. + unpinned-uses: + ignore: + - track.example.yml diff --git a/AGENTS.md b/AGENTS.md index 2136352..18c8844 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,15 @@ is the full contributor document and wins wherever the two disagree. - `src/` — the library and three binaries: `mossaic` (the chart), `mossaic-art` (the planner), `mossaic-glyphs`. `graphics.rs` is the rasteriser, `primer.rs` the GitHub colour tokens, `art.rs` the 5×5 font. -- `tests/` — `smoke.rs` and `pixels.rs` drive the real binary in a real PTY - through termlens; `art_cli.rs` drives the planner as a shell would. +- `tests/` — five files, four layers (CONTRIBUTING §3 has the rule for which + one a change belongs in): `art_cli.rs` drives the planner as a shell does + and `chart_cli.rs` the chart with no terminal at all — that pair is where a + CLI assertion goes; `smoke.rs` and `canvas_pty.rs` drive the real binary in + a real PTY through termlens; `pixels.rs` does the same in a PTY that + answers the graphics probe. +- `.claude/skills/termlens/SKILL.md` — the vendored termlens skill. PTY tests + follow it: content-based waits only, never a sleep; a readiness predicate + has to hold at the width under test. - `docs/DESIGN.md` — what was traded for what in the pixel path. **Read it before changing anything that emits kitty or sixel.** diff --git a/CHANGELOG.md b/CHANGELOG.md index 2af3826..68dde3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,150 @@ listed under a **Changed** or **Removed** heading. ## [Unreleased] +## [0.7.0] - 2026-09-06 + +Twenty findings and one security advisory, all reported against 0.6.3 with a +measured reproduction. The thread through most of them: something the tool +accepted, or printed, or counted, and then did other than what it said. + +### Security + +- **Escape sequences in a `.art` header reached the terminal, the saved plan + and the Action output** ([GHSA-jp9f-97rv-j4hx]). The three header fields — + `# name:`, `# author:`, `# description:` — were printed exactly as the file + wrote them, while every other piece of untrusted text went through + `printable()` where it enters, which is the rule `src/lib.rs` states in as + many words. A `.art` file is the one thing this project asks strangers to + send: #57 invites it and CONTRIBUTING §11 makes the review path "open the + file with `mossaic-art`", so a reviewer running `--matrix theirs.art`, or + anyone running `--list-templates`, executed whatever the header said — a + window-title change, an `OSC 52` clipboard write, or a cursor-position query + whose reply is typed back into the shell once the tool exits. Nothing warned, + because the point of an escape sequence is that it is not displayed, and + `--no-colour` suppressed it on no path. It travelled: through `--save` into + the plan and back out raw on reload, through `--format json`'s `headline`, + through `--format markdown` into `$GITHUB_STEP_SUMMARY`, and out of the + Action's `headline` output. `build.rs` embeds `art/templates/*.art`, so a + merged template would have shipped its payload to every user on every + listing. Now cleaned in the `Canvas` meta reader — one place, covering every + downstream printer — and bounded to 200 characters, since an unbounded + `# name:` produced a 200,061-byte first output line. + +### Added + +- **The terminal is given back on a signal.** Under a pty, `kill -INT`, + `-TERM` or `-HUP` on either binary emitted *zero bytes*: the shell was left + inside the alternate screen with mouse tracking on, ECHO/ICANON/ISIG off and + no working Ctrl-C, curable only by typing `reset` blind. `mossaic-art --draw` + had no panic hook either. Both now share one `restore` module, and the + process still reports as killed by the signal. Reported in #82. + +- **`start-week` and `columns` outputs on the Action**, and the placement in + the tracking header in both formats. action/README.md told readers "the + report prints the placement it used on its second line" — the only guard the + project offers against the one failure it calls silent and confident, + adopted by the consumer as its stated safety net — and no line in either + format carried it. Reported in #88. + +### Changed + +- **`--png`, `-o` and `--format` are refused where they do nothing.** Each was + accepted in every mode and honoured in one, at exit 0 with an empty stderr + and no file: `mossaic-art --template dragon --png preview.png` in a workflow + printed a cheerful report and produced nothing. #26 settled this principle + and enumerated three other flags; these were missed. Reported in #85. + +- **A plan's every key is checked, not just every value.** `background: 99` + was refused by name and `backgruond: 2` was accepted in silence, applying + the default — about 290 background days becoming keep-dark days, on the file + that is the input to `--backfill --write`. The cost is the forward + direction: a plan written by a newer mossaic is now refused too. + docs/ART.md's "Saving the plan" says so. Reported in #89. + +- **`--commits` is described as pricing the brightest day**, which is what it + does. "commits per lit day" applied to the shipped dragon predicts 584 where + the tool prints 442, and the help is the only description a `cargo install` + user gets. Reported in #93. + +### Fixed + +- **A closed pipe is not a crash.** `mossaic-art --font | less` and quitting, + `--list-templates | head`, `--format json | jq -e` with a jq that exits + early: all ordinary, all a panic at exit 101. `--png` was the one with a + price — a valid, complete PNG on disk and a status of 101, so a wrapper that + checks it deletes the file and retries. Reported in #83. + +- **The hyphen the font draws can start a text.** `-` is listed three times in + this project's own documents and `mossaic-art -` was `unknown option "-"`; + reaching for `--` gave `unknown option "--"`, so the escape hatch named + itself as the mistake. `--` now means the rest is positional, in the shared + parser, so a dash-led login works in the chart too. Reported in #92. + +- **The cost table counts the days the year has.** A full-width picture is 371 + cells against a year of 365, so the preview table disagreed with the header + above it and with what `--write` makes — immediately after a note saying + cells had been dropped. The legibility verdict came from the same raw + canvas, so a picture that drew nothing inside the year still reported + `shades 0 4 · ΔE 70, clear`. Reported in #78. + +- **The chart header counts what has happened.** It was the one figure + `--today` did not move: reading the shipped calendar as of March printed + December's 9,527 above a grid showing 2,043, beside "23 active days". + Reported in #81. + +- **`--track --save --format json` writes a document and nothing else.** The + `saved …` confirmation was the single `println!` that could run ahead of it, + at exit 0 with an empty stderr. Reported in #84. + +- **A template that does not parse is named.** The skip is deliberate policy; + the silence was not, and `--list-templates` is the command its own doc + comment calls "the command you would reach for to find out which one is + broken". `--template ` now gives the parse error rather than "no + template named", and a broken local file no longer silently shadows a + built-in. Reported in #86. + +- **A malformed `--file` is not blamed on `gh`.** The shared parser's only + wording was "unexpected response from gh", on a run where gh never executed. + Reported in #90. + +- **The release builds its own binaries.** `binaries.yml` and `install.yml` + sat on `on: release`, and GitHub raises no workflow-starting event for a + release created with `GITHUB_TOKEN` — so neither had ever run from that + trigger, and every release from 0.6.0 on had no binaries until somebody + dispatched two workflows by hand. Reported in #73. + +- **An empty CHANGELOG section fails before `cargo publish`.** The extraction + ran only after the publish, so a blank section put the version permanently + on crates.io and *then* failed the release. The script also tells "absent" + from "present and empty", and the oldest section no longer absorbs the link + block. Reported in #80. + +- **The example workflow is audited.** `track.example.yml` tells you to copy + it into a public repository of your own and was the one file the zizmor job + never read: whoever followed it inherited workflow-level `issues: write` for + every step, including one running a mutable tag on somebody else's + repository. Reported in #87. + +- **One Action run asks GitHub once per day.** The two `--format` calls each + made their own query and their own `Local::now()`, so across a local + midnight the gate and the message were about different days. Reported in + #79. + +- **`fail-on: holed` names the picture.** Its only message interpolated + `text:`, which is empty for `template:`, `matrix:` and `image:`. Reported in + #91. + +- **The three plural sites 0.6.3 missed**, and the markdown sentence it + reworded into nonsense — the verb moved in front of "inside the letters", so + "already lit" became a clause on the letters rather than the days, on the + path the Action publishes. The sixteen quoted blocks in README and + docs/ART.md are regenerated, and a test now asserts in both directions that + the tool prints what the pages quote. Reported in #94. + +- **CONTRIBUTING names all five test files** and counts them correctly; §3 is + four layers with the file named for each. It named two of five and was 39% + under. Reported in #95. + ## [0.6.3] - 2026-08-24 ### Added @@ -923,6 +1067,7 @@ there was none. [termlens]: https://github.com/vyncint/termlens [Unreleased]: https://github.com/vyncint/mossaic/compare/v0.6.3...HEAD +[0.7.0]: https://github.com/vyncint/mossaic/compare/v0.6.3...v0.7.0 [0.6.3]: https://github.com/vyncint/mossaic/compare/v0.6.2...v0.6.3 [0.6.2]: https://github.com/vyncint/mossaic/compare/v0.6.1...v0.6.2 [0.6.1]: https://github.com/vyncint/mossaic/compare/v0.6.0...v0.6.1 @@ -934,3 +1079,4 @@ there was none. [0.2.0]: https://github.com/vyncint/mossaic/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/vyncint/mossaic/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/vyncint/mossaic/releases/tag/v0.1.0 +[GHSA-jp9f-97rv-j4hx]: https://github.com/vyncint/mossaic/security/advisories/GHSA-jp9f-97rv-j4hx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70092d2..76c8f4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ getting along with terminals none of us has. ```sh git clone https://github.com/vyncint/mossaic cd mossaic -cargo test # 150-odd tests, hermetic and offline +cargo test # 223-odd tests, hermetic and offline cargo run # the chart, for whoever `gh` is logged in as ``` @@ -42,7 +42,10 @@ that is `rust-version` in `Cargo.toml`, verified by the `msrv` job. | `src/{ui,app}.rs` | rendering and layout; state, keys and mouse | | `src/{art,png}.rs` | the 5×5 font and its costing; a small PNG encoder | | `src/render_tests.rs` | in-process tests: layout, colour, encoders, art, PNG | +| `tests/art_cli.rs` | the planner driven as a shell drives it, no PTY | +| `tests/chart_cli.rs` | the chart with no terminal at all: a script, a pipe, CI | | `tests/smoke.rs` | out-of-process tests: the real binary in a real PTY | +| `tests/canvas_pty.rs` | the editor and the template list, in a real PTY | | `tests/pixels.rs` | the same, in a PTY that answers the graphics probe | | `docs/ART.md` | drawing text into a graph, and tracking the plan | | `docs/DESIGN.md` | why the pixel path is shaped the way it is | @@ -50,16 +53,23 @@ that is `rust-version` in `Cargo.toml`, verified by the `msrv` job. ## 3. Testing policy -Every behavioural change needs a test, and which of the three layers it belongs +Every behavioural change needs a test, and which of the four layers it belongs in is usually obvious: - **In process** (`src/render_tests.rs`) for anything that is a function of inputs: layout maths, hit-testing, palettes, the encoders, the art font. Encoders are tested against the formats, not against themselves — the sixel is decoded back into pixels and compared to what the rasteriser drew. -- **Out of process** (`tests/smoke.rs`, through +- **Out of process, no PTY** (`tests/art_cli.rs` for `mossaic-art`, + `tests/chart_cli.rs` for the chart) for a command that prints and exits: a + flag's contract, an error message, an exit code, a file written. This is + where a new CLI assertion belongs, and it is the largest layer — a command + that needs no terminal should not be tested through one. +- **Out of process, in a PTY** (`tests/smoke.rs` for the chart, + `tests/canvas_pty.rs` for the editor and the template list, both through [termlens](https://crates.io/crates/termlens)) for anything that involves the - event loop, the PTY, or escapes written around ratatui rather than through it. + event loop, the PTY, or escapes written around ratatui rather than through it + — including what happens on a signal, which is out-of-process by definition. - **Out of process, with pixels** (`tests/pixels.rs`) for anything that depends on the terminal *answering* the capability probe. Declare what is being simulated — `.graphics(Graphics::Kitty).cell_size(9, 19)` — rather than forcing the outcome diff --git a/Cargo.lock b/Cargo.lock index 673d705..9a61fbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,9 +139,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.4" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "shlex", @@ -300,7 +300,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -311,7 +311,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -439,9 +439,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "finl_unicode" @@ -619,7 +619,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -639,9 +639,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -721,9 +721,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" -version = "0.18.2" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" dependencies = [ "hashbrown 0.17.1", ] @@ -776,9 +776,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", @@ -788,7 +788,7 @@ dependencies = [ [[package]] name = "mossaic" -version = "0.6.3" +version = "0.7.0" dependencies = [ "chrono", "libc", @@ -796,6 +796,7 @@ dependencies = [ "ratatui", "serde", "serde_json", + "signal-hook", "termlens", ] @@ -942,9 +943,9 @@ dependencies = [ [[package]] name = "pest" -version = "2.9.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" dependencies = [ "memchr", "ucd-trie", @@ -952,9 +953,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.9.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" dependencies = [ "pest", "pest_generator", @@ -962,9 +963,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.9.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" dependencies = [ "pest", "pest_meta", @@ -975,9 +976,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.9.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" dependencies = [ "pest", ] @@ -1105,9 +1106,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "rand_core", ] @@ -1330,7 +1331,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -1441,9 +1442,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "static_assertions" @@ -1502,9 +1503,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -1560,9 +1561,9 @@ dependencies = [ [[package]] name = "termlens" -version = "0.7.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037018c17834c72ff38b952b5de436b67cb396c43e5c12a839cbf79b0ceb0613" +checksum = "0fca989672430e13284b48504c44b499d88f5b39cb36b0e8dc5b45f06df50b09" dependencies = [ "insta", "libc", @@ -1652,7 +1653,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -1678,9 +1679,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ "tinyvec_macros", ] @@ -1749,9 +1750,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.25.0" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "atomic", "getrandom 0.4.3", @@ -1812,9 +1813,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -1825,9 +1826,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1835,22 +1836,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] diff --git a/Cargo.toml b/Cargo.toml index 5103d91..ee88670 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mossaic" -version = "0.6.3" +version = "0.7.0" edition = "2021" # Minimum supported Rust version. Verified by the `msrv` CI job, which reads this # field; bumping it is a minor (not patch) change. @@ -38,12 +38,17 @@ miniz_oxide = "0.9" # O_NONBLOCK, to read the terminal's capability replies without blocking on a # terminal that never answers. One constant, no unsafe. libc = "0.2" +# Restoring the terminal on SIGINT/SIGTERM/SIGHUP without an unsafe +# `sigaction`. Unix-only by nature — Windows has no POSIX signals — and +# already in the tree at this version, since crossterm pulls it through +# ratatui for its event stream. +signal-hook = { version = "0.3", default-features = false, features = ["iterator"] } [dev-dependencies] # The end-to-end suite: the real binary, in a real PTY, asserted on the rendered # screen — and, with `decode`, on the pixels of the images that went out over # kitty and sixel, which no rendered screen can show. -termlens = { version = "0.7.0", features = ["decode"] } +termlens = { version = "0.9", features = ["decode"] } [profile.release] lto = true diff --git a/README.md b/README.md index 0c59936..c625f98 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ mossaic-art --track # how far along, and what today owes ``` letters ██████████████████░░░░░░░░░░ 50 of 75 bright - owing 25 day(s) short, 100 contributions between them + owing 25 days short, 100 contributions between them VYNCINT can still be drawn cleanly — 100 contributions to go. ``` @@ -145,7 +145,7 @@ placement that would salvage the most: ``` VYNCINT cannot be drawn cleanly in 2026. - 61 day(s) inside the letters already have contributions, and + 61 days inside the letters already have contributions, and nothing takes those away — the text would read with holes in it. --start-week 1 would leave 23 instead of 61. ``` @@ -535,9 +535,12 @@ cargo install mossaic --locked ```sh cargo test # everything, no network -cargo test --test smoke # the real binary, in a real pty -cargo test --test pixels # …in a pty that says it can draw pixels -cargo test -- --ignored # the two that call the GitHub API +cargo test --test art_cli # the planner, driven as a shell drives it +cargo test --test chart_cli # the chart with no terminal at all +cargo test --test smoke # the real binary, in a real pty +cargo test --test canvas_pty # the editor and the template list, in a pty +cargo test --test pixels # …in a pty that says it can draw pixels +cargo test -- --ignored # the two that call the GitHub API ``` Three test layers, because they catch different things: in-process for anything diff --git a/SECURITY.md b/SECURITY.md index cb565a4..0d56e8c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -47,9 +47,27 @@ What the project does continuously, enforced by required CI on every change: ## What has been found and fixed -The 0.1.0 review, in the order the findings mattered. Each has a regression -test named after it in `src/render_tests.rs`. - +The 0.1.0 review, in the order the findings mattered, and what has been found +since. Each has a regression test named after it in `src/render_tests.rs`. + +- **Escape sequences in a `.art` header reached the terminal, the saved plan + and the Action output** (GHSA-jp9f-97rv-j4hx, fixed in 0.7.0). The three + header fields — `# name:`, `# author:`, `# description:` — were printed + exactly as the file wrote them, while every other piece of untrusted text + went through `printable()` where it enters. A `.art` file is the one thing + this project asks strangers to send: a reviewer running + `mossaic-art --matrix theirs.art`, or anyone running `--list-templates`, + executed whatever the header said — a window-title change, an `OSC 52` + clipboard write, or a cursor-position query whose reply is typed back into + the shell after the tool exits. `--no-colour` suppressed none of it. It rode + through `--save` into the plan, through `--format json`'s `headline`, through + `--format markdown` into `$GITHUB_STEP_SUMMARY` and out of the Action's + `headline` output; and `build.rs` embeds `art/templates/*.art`, so a merged + template would have shipped its payload to every user. The header is now + cleaned in the `Canvas` meta reader — where it enters, covering every + downstream printer at once — and bounded to 200 characters, since an + unbounded `# name:` produced a 200,061-byte first output line. + This is the same finding as the calendar-path one below, one file over. - **Escape sequences from a calendar reached the terminal.** A crafted `--file` snapshot, or a hostile API response, could put `ESC` into the login or an error message. The renderer was never the problem — ratatui drops diff --git a/action/README.md b/action/README.md index 77f2f50..10f561c 100644 --- a/action/README.md +++ b/action/README.md @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - id: art - uses: vyncint/mossaic/action@v0.6.3 + uses: vyncint/mossaic/action@v0.7.0 with: text: VYNCINT year: "2027" @@ -84,7 +84,7 @@ drawn. The rest are the same either way. ```yaml - id: art - uses: vyncint/mossaic/action@v0.6.3 + uses: vyncint/mossaic/action@v0.7.0 with: template: dragon year: "2027" @@ -103,8 +103,12 @@ than reporting a missing file. **The plan is these inputs.** Tracking with a different `start-week` — or a different `background` — compares against a different plan and reports nonsense -confidently. The report prints the placement it used on its second line; if that -ever changes, so did your plan. +confidently. The report prints the placement it used **in its header**, in both +formats — `### Heart · 2026 · week 35, 11 columns` — so if that ever changes, +so did your plan. The `start-week` and `columns` outputs carry the same two +numbers, which is the version worth asserting on: the figures in a report +legitimately change every day, so a changed plan is otherwise indistinguishable +from a normal day's drift. **`background`** turns the rest of the year into part of the picture rather than something to keep dark. `background: "1"` draws the letters at level 4 on @@ -216,7 +220,7 @@ on 290 days of the year is a job nobody reads. ## Notes -- **Two knobs, two jobs.** The ref you pin (`@v0.6.3`, `@main`) chooses the +- **Two knobs, two jobs.** The ref you pin (`@v0.7.0`, `@main`) chooses the *action's steps* — the glue that runs the tracker and shapes the outputs. The `version` input chooses the *tracker itself*, straight from crates.io. The default, `latest`, is fine for a daily report; pin a number when you diff --git a/action/action.yml b/action/action.yml index 6ff2555..4f8c845 100644 --- a/action/action.yml +++ b/action/action.yml @@ -118,7 +118,7 @@ inputs: default: "true" version: description: >- - Which mossaic release runs the tracking, e.g. "0.6.3". The default, + Which mossaic release runs the tracking, e.g. "0.7.0". The default, `latest`, installs the newest release on crates.io each run — fine for tracking, since the report format is versioned with the crate. Pin a number if you want the tracker to change only when you say so. @@ -178,6 +178,18 @@ outputs: field-level: description: The level the background is drawn at. `0` when there is none. value: ${{ steps.track.outputs.field-level }} + start-week: + description: >- + The calendar column the plan starts at — **the placement it tracked + against**. Tracking with a different `start-week` compares against a + different plan and reports nonsense confidently, and the numbers in a + report legitimately change every day, so a changed plan is otherwise + indistinguishable from a normal day's drift. Assert this in your + workflow and the guard becomes a test rather than something to read for. + value: ${{ steps.track.outputs.start-week }} + columns: + description: How many calendar columns the plan spans. + value: ${{ steps.track.outputs.columns }} legibility: description: >- `clear`, `readable` or `faint` — how well the letters stand out from the @@ -312,7 +324,21 @@ runs: if [ -n "${INPUT_YEAR}" ]; then args+=(--year "${INPUT_YEAR}"); fi if [ -n "${INPUT_START_WEEK}" ]; then args+=(--start-week "${INPUT_START_WEEK}"); fi + # Two renderings, one day. Each run makes its own GraphQL query and + # its own `Local::now()`, so across a local midnight the halves + # landed on different days: the json call reported `today-short=2` + # while the markdown body said "36 to go", and nothing in the message + # named the date, so the split was invisible. The consumer gates on + # the json output and posts the markdown, and with auto-commit that + # back-dated 2 empty commits for a day that had already ended. + # + # CONTRIBUTING §3 already states the rule this broke: "Pin `--today` + # in anything that reads a report." Neither call pinned it. "${ART}" "${args[@]}" --format json > "${report_dir}/report.json" + pinned_today="$(jq -r '.today.date // empty' "${report_dir}/report.json")" + if [ -n "${pinned_today}" ]; then + args+=(--today "${pinned_today}") + fi "${ART}" "${args[@]}" --format markdown > "${report_dir}/report.md" # Multi-line outputs need a delimiter the value cannot contain. @@ -341,6 +367,8 @@ runs: "today-kind=" + (.today.kind // "outside"), "tomorrow-kind=" + (.tomorrow.kind // "outside"), "field-level=" + (.field_level | tostring), + "start-week=" + (.start_week | tostring), + "columns=" + (.columns | tostring), "legibility=" + .legibility, "separation=" + (.separation | floor | tostring), "field-days=" + (.field_days | tostring), @@ -354,6 +382,16 @@ runs: fi verdict="$(jq -r .verdict "${report_dir}/report.json")" + # From the report, not from `text:`. `INPUT_TEXT` is empty by + # construction for `template:`, `matrix:` and `image:` — the step + # refuses more than one source — so the `holed` gate's only message + # arrived as a sentence with a hole where the noun should be, in a + # red annotation on an unattended cron. A picture is the case where + # `holed` is most likely, since it uses the whole week. `.text` is + # `VYNCINT` for text, `Dragon` for a template and `heart` for a + # matrix. Closed #9 fixed the same shape in the `behind` gate. + subject="$(jq -r '.text // empty' "${report_dir}/report.json")" + [ -n "${subject}" ] || subject="this plan" today_short="$(jq -r '.today.short // 0' "${report_dir}/report.json")" # A background day is short too, and `behind` is documented as being # about letter days. Without this, `background: "1"` failed the job on @@ -362,7 +400,7 @@ runs: case "${INPUT_FAIL_ON}" in holed) if [ "${verdict}" = "holed" ]; then - echo "::error::${INPUT_TEXT} can no longer be drawn cleanly this year" + echo "::error::${subject} can no longer be drawn cleanly this year" exit 1 fi ;; diff --git a/action/track.example.yml b/action/track.example.yml index 1bc06b2..01e61d5 100644 --- a/action/track.example.yml +++ b/action/track.example.yml @@ -12,17 +12,24 @@ on: # So you can run it now instead of waiting for tomorrow. workflow_dispatch: +# Read-only at the workflow level. `issues: write` is granted to the one +# step that needs it, further down — a token every step can write issues +# with, in a workflow that runs unattended on a cron and calls a third +# party's action, is more scope than any of this needs. permissions: contents: read - # Only needed by the issue-comment step below. - issues: write + +concurrency: + group: contribution-art + cancel-in-progress: false jobs: track: + name: track runs-on: ubuntu-latest steps: - id: art - uses: vyncint/mossaic/action@v0.6.3 + uses: vyncint/mossaic/action@v0.7.0 with: text: VYNCINT year: "2027" @@ -39,7 +46,7 @@ jobs: # the failure notification you already get does the reminding. fail-on: never # Which mossaic release does the tracking. The default, latest, - # follows crates.io; pin a number ("0.6.3") to freeze it. + # follows crates.io; pin a number ("0.7.0") to freeze it. # version: latest # ----------------------------------------------------------------- issue @@ -47,6 +54,10 @@ jobs: # and put its number here. - name: Comment on the tracking issue if: steps.art.outputs.today-short != '0' + # The only step that writes anything back to this repository. + permissions: + contents: read + issues: write env: GH_TOKEN: ${{ github.token }} BODY: ${{ steps.art.outputs.markdown }} @@ -78,7 +89,10 @@ jobs: # ----------------------------------------------------------------- email - name: Email if: vars.EMAIL_ENABLED == 'true' - uses: dawidd6/action-send-mail@v18 + # SHA-pinned, like every action in this repository's own workflows: + # a mutable tag on somebody else's repository, in a workflow that + # runs unattended, is a supply chain you do not control. + uses: dawidd6/action-send-mail@94de994a9f6fffee200243214e17002e2920bb59 # v18 with: server_address: smtp.gmail.com server_port: 465 diff --git a/docs/ART.md b/docs/ART.md index 6c33a02..1f8379e 100644 --- a/docs/ART.md +++ b/docs/ART.md @@ -71,7 +71,7 @@ falls in December of the year before. `mossaic-art` says so rather than drawing letter: ``` -note: 3 pixel(s) fell outside 2027 and were dropped — the first and last calendar +note: 3 lit pixels fell outside 2027 and were dropped — the first and last calendar columns are partial weeks, so 52 of 53 columns hold a whole letter ``` @@ -103,7 +103,7 @@ Mon ░░░░░░░░░░██░░░░░░██░░██ ░░░░░░░░░░██░░░░░░██░░░░██░░██░░░░████░░░░██░░██░░░░░░██░░░░░░██░░░░░░ Wed ░░░░░░░░░░██░░░░░░██░░░░░░██░░░░░░██░░██░░██░░██░░░░░░░░░░░░░░██░░░░░░ -background level 1 under letters at level 4 · 290 background day(s), 1 each +background level 1 under letters at level 4 · 290 background days, 1 each · ΔE 35 at worst, clear ``` @@ -186,7 +186,7 @@ of work — three hundred easy days would otherwise drown out seven hard ones: letters ████████████████████████████ 75 of 75 bright background ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0 of 290 at level 1 - owing 290 background day(s) short, 290 contributions + owing 290 background days short, 290 contributions ``` The preview marks each state: `██` a letter that is bright enough, `▒▒` one @@ -323,7 +323,7 @@ mossaic-art --track 2 71 0 114 2 0 219 186 - must stay dark - still owing 104 day(s) · 302 contributions + still owing 104 days · 302 contributions today must stay dark tomorrow must stay dark ``` @@ -365,15 +365,15 @@ VYNCINT · 2026 · tracking art/vyncint-2026.json a letter day has to reach 110 to match it letters ██████░░░░░░░░░░░░░░░░░░░░░░ 18 of 75 bright - owing 57 day(s) short, 5,994 contributions between them - holes 61 day(s) inside the letters are lit and cannot be unlit - around 23 day(s) outside the text have contributions + owing 57 days short, 5,994 contributions between them + holes 61 days are lit inside the letters and cannot be unlit + around 23 days outside the text with contributions VYNCINT cannot be drawn cleanly in 2026. - 61 day(s) inside the letters already have contributions, and + 61 days inside the letters already have contributions, and nothing takes those away — the text would read with holes in it. --start-week 1 would leave 23 instead of 61. @@ -390,8 +390,8 @@ VYNCINT · 2026 · tracking art/vyncint-2026.json Tue Aug 25 keep dark the rest of the year - 23 letter day(s) still to come, 2,530 contributions - 34 letter day(s) already past, 3,464 contributions — only back-dated + 23 letter days still to come, 2,530 contributions + 34 letter days already past, 3,464 contributions — only back-dated commits reach those: mossaic-art VYNCINT --year 2026 --start-week 6 --top 1 --backfill --repo ../art --write @@ -450,17 +450,17 @@ mossaic-art --backfill --repo ../art --write # write it, locally # with --merge art/vyncint-2026.json --today 2026-08-19, to reproduce this exactly VYNCINT · 2026 · backfilling against art/vyncint-2026.json - letters 57 day(s) short, 5,994 commits + letters 57 days short, 5,994 commits a day gets what it is short of 110, never a flat count reaching days before 2026-08-19, which are the ones only back-dating reaches - 23 day(s) from 2026-08-19 on are short too, and left alone — contribute on those as they come + 23 days from 2026-08-19 on are short too, and left alone — contribute on those as they come - warning: VYNCINT cannot be drawn cleanly in 2026 — 61 day(s) inside the + warning: VYNCINT cannot be drawn cleanly in 2026 — 61 days inside the letters are already lit, and nothing takes those away. Backfilling will brighten the letters, and the text will still read with holes in it. `mossaic-art --track` sweeps --start-week for a placement with fewer. - 3,464 commit(s) across 34 day(s), earliest 2026-02-09, latest 2026-08-14 + 3,464 commits across 34 days, earliest 2026-02-09, latest 2026-08-14 (add --write to create them; this was a dry run) ``` @@ -507,7 +507,7 @@ arrive rather than be asked for: ```yaml - id: art - uses: vyncint/mossaic/action@v0.6.3 + uses: vyncint/mossaic/action@v0.7.0 with: text: VYNCINT year: "2027" @@ -584,3 +584,19 @@ The file stores the placement *resolved*, so a text that was centred keeps the column it was centred on. Typed flags still win over the saved ones, so `--year 2028` is a one-off rather than a surprise. `--plan PATH` puts the file somewhere else. + +**A plan is version-locked to the tool that wrote it.** Every key is checked, +not just every value: a plan carrying a key this build does not recognise is +refused by name rather than applied at its default. That closes the case +where `backgruond: 2` — one transposition in a hand edit — silently turned +about 290 background days into keep-dark days at exit 0, on the file that is +the input to `--backfill --write`, where contributions cannot be unlit. The +cost is the other direction: a plan written by a *newer* mossaic is refused +too. Save it again with the version you are running. + +**A picture plan needs a mossaic that understands `art`.** A plan saved from +`--template`, `--matrix` or `--image` stores the picture inline in the `art` +key. An older build ignores that key and falls back to drawing the `text` +field, which for a picture is its name — so a 146-day dragon becomes a +79-day word, at exit 0, and `--backfill` then asks for a different date range +and a different total. diff --git a/src/art.rs b/src/art.rs index 339ed95..a1f87d1 100644 --- a/src/art.rs +++ b/src/art.rs @@ -802,7 +802,12 @@ impl Canvas { .flat_map(|column| column.iter().copied()) } - /// How many days sit at each level, indexed by level. + /// How many *cells* sit at each level, indexed by level. + /// + /// This counts the canvas, which is 7 x width — not the year. Use it for + /// questions about the drawing itself; for anything a user budgets + /// against, ask [`levels_histogram`] about the days the calendar + /// actually has. #[must_use] pub fn histogram(&self) -> [usize; 5] { let mut counts = [0usize; 5]; @@ -826,7 +831,12 @@ impl Canvas { /// Every level the picture uses, darkest first. #[must_use] pub fn palette(&self) -> Vec { - let histogram = self.histogram(); + Self::palette_of(&self.histogram()) + } + + /// The same, from a histogram somebody else counted. + #[must_use] + pub fn palette_of(histogram: &[usize; 5]) -> Vec { (0..=4u8) .filter(|level| histogram[usize::from(*level)] > 0) .collect() @@ -849,7 +859,20 @@ impl Canvas { /// `None` for a canvas of one shade, which has no pair to compare. #[must_use] pub fn closest_pair(&self) -> Option<(u8, u8, Legibility, f32)> { - let used = self.palette(); + Self::closest_pair_of(&self.histogram()) + } + + /// The same, over the shades a given histogram holds. + /// + /// Taking the histogram rather than the canvas is what lets the preview + /// ask about the shades that land *inside the year*: a picture whose only + /// ink falls in the partial weeks at either end drew nothing, and used to + /// report `shades 0 4 · closest pair 0 and 4 · ΔE 70, clear` — the one + /// check this project tells you to read twice, passing on a drawing that + /// does not exist. + #[must_use] + pub fn closest_pair_of(histogram: &[usize; 5]) -> Option<(u8, u8, Legibility, f32)> { + let used = Self::palette_of(histogram); let mut worst: Option<(u8, u8, f32)> = None; for (index, low) in used.iter().enumerate() { for high in used.iter().skip(index + 1) { @@ -870,6 +893,8 @@ impl Canvas { /// The busiest day this canvas needs the year to have before its shades can /// be told apart. /// + /// (see [`levels_histogram`] for the calendar-side counterpart) + /// /// GitHub's scale has four steps, so a year whose busiest day is 1 holds /// exactly two shades: empty and full. A picture using any level between /// needs a peak of at least 4, where the counts 1, 2, 3, 4 land on levels @@ -962,16 +987,69 @@ impl Canvas { } } +/// The most a header field may carry. +/// +/// A `# name:` is a title in a listing and the first word of a report line, so +/// a few dozen characters is generous. Unbounded, a 200,000-character name +/// produced a 200,061-byte first output line — and rode through `--save` into +/// the plan and out of the Action's `headline` output. +const MAX_META: usize = 200; + +/// How many days sit at each level, over the days the calendar actually has. +/// +/// The canvas-side [`Canvas::histogram`] counts cells — 7 x width, which for +/// a full-width picture is 371 against a year of 365 or 366. The preview +/// table and the editor panel used it, so they disagreed with the header +/// above them and with what `--write` makes: a picture with ink in the +/// partial weeks at either end priced out 322 days and 742 commits while the +/// header said 317 and 722, and `--write` made 722. The note directly above +/// the table had just said cells were dropped, and the table counted them +/// anyway. +/// +/// The tracking renderer always did it this way, which is why the shipped +/// docs disagree with themselves: `docs/ART.md` prints one figure for the +/// preview and another for the tracking table of the same plan, exactly the +/// out-of-year cells apart. +#[must_use] +pub fn levels_histogram(levels: &std::collections::BTreeMap) -> [usize; 5] { + let mut counts = [0usize; 5]; + for level in levels.values() { + counts[usize::from(*level).min(4)] += 1; + } + counts +} + /// Read one `# key: value` header line into `meta`. /// /// Unknown keys are ignored rather than refused. A `.art` file is a document as /// well as data — a contributor may want a `# note:` line — and a format that /// rejects a comment it does not recognise is one that breaks when it grows. +/// +/// **The value is cleaned here**, which is the rule `crate::printable` states: +/// untrusted text is cleaned where it enters rather than at each of the places +/// that print it. A `.art` file is exactly the thing this project asks +/// strangers to send — issue #57 invites it, and CONTRIBUTING §11 makes the +/// review path "open the file with `mossaic-art`" — so a control character in +/// a header reached the reviewer's terminal, the saved plan, the JSON and +/// markdown reports and the Action's `headline` output, unfiltered, on a +/// binary that had already fixed this same bug on the calendar path. +/// `--no-colour` suppressed none of it, because the point of an escape +/// sequence is that it is not displayed. +/// +/// Cleaning here covers every downstream printer at once: the report header, +/// `--list-templates`, both report formats, the saved plan's `art` string and +/// the editor's title. fn read_meta(comment: &str, meta: &mut Meta) { let Some((key, value)) = comment.split_once(':') else { return; }; - let value = value.trim().to_string(); + // `printable` first, then trim: an escape sequence around whitespace + // would otherwise leave the whitespace behind. + let value: String = crate::printable(value.trim()) + .trim() + .chars() + .take(MAX_META) + .collect(); if value.is_empty() { return; } diff --git a/src/bin/mossaic-art.rs b/src/bin/mossaic-art.rs index a89775c..64660ad 100644 --- a/src/bin/mossaic-art.rs +++ b/src/bin/mossaic-art.rs @@ -58,7 +58,8 @@ WHAT TO DRAW WHERE IT GOES --year YEAR which year's calendar (default: this one) --start-week N left edge, in weeks (default: centred) - --commits N commits per lit day (default 4) + --commits N commits for the brightest day; darker shades are priced + against it (default 4) --top ROW first calendar row, 0 = Sunday (default 1, so Mon-Fri). Text only; a picture uses all seven rows --background LEVEL (--bg) draw the background as a shade 0-3 rather than @@ -113,6 +114,8 @@ also installed: mossaic-glyphs what this terminal makes of the fallback cells"#; fn main() { + // Before anything prints: a reader that closes early is not a crash. + mossaic::quiet_broken_pipe(); let Some(options) = parse_args() else { return; }; @@ -230,7 +233,15 @@ fn main() { }; spec.save(&options.plan_path) .unwrap_or_else(|error| fail(&error)); - println!( + // stderr, not stdout. Under `--format json` or `--format markdown` + // stdout is a *document*: this was its first line, so + // `--track --save --format json | jq .` handed a parser two lines of + // prose and still exited 0, with an empty stderr — no signal + // anywhere. Every other note a track run makes already goes here; + // this was the single `println!` that could run ahead of the + // document. It bit only on the run that writes the plan, so it read + // as a flake on first use. + eprintln!( "saved {} — from now on:\n mossaic-art --track\n", options.plan_path.display() ); @@ -259,13 +270,15 @@ fn main() { let art_level = art::level(options.commits, peak); let total = placed.total(); println!( - "{} · {} · {} of {} columns · {} days · {} commits\n", + "{} · {} · {} of {} columns · {} {} · {} {}\n", options.text.to_uppercase(), grid.year, columns.len(), grid.weeks, placed.lit.len(), + plural(placed.lit.len(), "day", "days"), thousands(total), + plural(total, "commit", "commits"), ); let palette = options @@ -428,6 +441,10 @@ fn run_editor(options: &Options, grid: &Grid, name: &str, canvas: art::Canvas) { let mut terminal = ratatui::try_init() .unwrap_or_else(|error| fail(&format!("--draw needs an interactive terminal ({error})"))); + // The editor takes the alternate screen and enables mouse reporting, and + // until 0.7.0 had neither a panic hook nor a signal handler — so both of + // the exits nobody writes code for left the terminal borrowed. + mossaic::restore::guard_terminal(); let mut out = std::io::stdout(); let _ = execute!(out, EnableMouseCapture); let palette = mossaic::draw::palette(); @@ -501,7 +518,11 @@ fn run_editor(options: &Options, grid: &Grid, name: &str, canvas: art::Canvas) { /// `--list-templates`: the catalogue, with where each one came from. fn show_templates(colour: bool) { let catalogue = templates::catalogue(); - if catalogue.is_empty() { + // Read before the early return: a directory holding only broken files + // used to print "no templates installed", which is the least helpful + // true sentence available. + let skipped = templates::skipped(); + if catalogue.is_empty() && skipped.is_empty() { println!("no templates installed"); return; } @@ -560,7 +581,26 @@ fn show_templates(colour: bool) { } println!(); } - println!("draw one: mossaic-art --template {}", catalogue[0].name); + // What was skipped, and why. `templates::read_dir`'s own doc comment + // already called this command "the command you would reach for to find + // out which one is broken" — and it named nothing, counted nothing and + // wrote no byte to stderr. #57's walkthrough puts a first-time + // contributor on exactly this path: `cp your-name.art templates/ && + // mossaic-art --list-templates`. + if !skipped.is_empty() { + println!( + "{} {} skipped:", + skipped.len(), + plural(skipped.len(), "file was", "files were") + ); + for broken in &skipped { + println!(" {}: {}", broken.file, broken.why); + } + println!(); + } + if let Some(first) = catalogue.first() { + println!("draw one: mossaic-art --template {}", first.name); + } } /// Everything a run that draws a **picture** does, from preview to commits. @@ -610,7 +650,15 @@ fn run_canvas(options: &Options, grid: &Grid, name: &str, canvas: &art::Canvas) }; spec.save(&options.plan_path) .unwrap_or_else(|error| fail(&error)); - println!( + // stderr, not stdout. Under `--format json` or `--format markdown` + // stdout is a *document*: this was its first line, so + // `--track --save --format json | jq .` handed a parser two lines of + // prose and still exited 0, with an empty stderr — no signal + // anywhere. Every other note a track run makes already goes here; + // this was the single `println!` that could run ahead of the + // document. It bit only on the run that writes the plan, so it read + // as a flake on first use. + eprintln!( "saved {} — from now on:\n mossaic-art --track\n", options.plan_path.display() ); @@ -650,14 +698,21 @@ fn run_canvas(options: &Options, grid: &Grid, name: &str, canvas: &art::Canvas) return; } - let histogram = canvas.histogram(); + // The days the *calendar* holds, not the cells the canvas has. A + // full-width picture is 7 x 53 = 371 cells against a year of 365, and the + // table used to count all of them — disagreeing with the header directly + // above it and with what `--write` makes, after a note that had just said + // cells were dropped. + let histogram = art::levels_histogram(&levels); println!( - "{name} · {} · {} of {} columns · {} days · {} commits\n", + "{name} · {} · {} of {} columns · {} {} · {} {}\n", grid.year, canvas.width(), grid.weeks, commits.len(), + plural(commits.len(), "day", "days"), thousands(total), + plural(total, "commit", "commits"), ); let palette = options @@ -682,8 +737,17 @@ fn run_canvas(options: &Options, grid: &Grid, name: &str, canvas: &art::Canvas) // Whether a reader will see a picture or a smudge. The pair measured is the // darkest and brightest the drawing actually uses: if those two are faint, // everything between them is worse. - if let Some((low, high, legibility, delta)) = canvas.closest_pair() { - let used: Vec = canvas.palette().iter().map(u8::to_string).collect(); + // Asked about the shades that land inside the year. A picture whose only + // ink falls in the partial weeks drew nothing and still reported + // `shades 0 4 · ΔE 70, clear`: the one check this project tells you to + // read twice, passing on a drawing that does not exist. `closest_pair_of` + // returns `None` for a single shade, so such a run now prints no verdict + // at all rather than a flattering one. + if let Some((low, high, legibility, delta)) = art::Canvas::closest_pair_of(&histogram) { + let used: Vec = art::Canvas::palette_of(&histogram) + .iter() + .map(u8::to_string) + .collect(); println!( "\n shades {} · closest pair {low} and {high} · ΔE {delta:.0}, {legibility}", used.join(" ") @@ -781,7 +845,18 @@ fn track_canvas( return; } - println!("{name} · {} — tracking {who}\n", grid.year); + // The placement, for the same reason the text path prints it below its + // own header: tracking with a different `--start-week` compares against + // a different plan and reports nonsense confidently. The text path had + // "the plan N of M columns from week W"; the picture path had nothing, + // and a picture is what the shipped consumer tracks. + println!( + "{name} · {} · week {}, {} {} — tracking {who}\n", + grid.year, + plan.start_week, + plan.columns, + plural(plan.columns, "column", "columns") + ); println!("{}\n", art::preview(levels, grid, palette.as_ref())); let (owing_days, owing_commits) = plan.owing(); @@ -1081,13 +1156,16 @@ fn backfill( // commits changes that. if let plan::Verdict::Holed { holes } = plan.verdict() { println!( + // "are" — 0.6.3's plural pass dropped the verb here, so the + // sentence read "61 days inside the letters already lit". "\n warning: {} cannot be drawn cleanly in {} — {holes} {} inside the\n \ - letters already lit, and nothing takes those away. Backfilling will\n \ + letters {} already lit, and nothing takes those away. Backfilling will\n \ brighten the letters, and the text will still read with holes in it.\n \ `mossaic-art --track` sweeps --start-week for a placement with fewer.", plan.text, plan.year, - plural(holes, "day", "days") + plural(holes, "day", "days"), + plural(holes, "is", "are") ); } @@ -1225,7 +1303,14 @@ fn track_progress( // tracking with a different --start-week than the text was drawn with // compares against a different plan entirely. Printing which one is on // screen makes that visible rather than baffling. - println!("{} · {} · tracking {who}\n", plan.text, plan.year); + println!( + "{} · {} · week {}, {} {} · tracking {who}\n", + plan.text, + plan.year, + plan.start_week, + plan.columns, + plural(plan.columns, "column", "columns") + ); println!( " the plan {} of {} columns from week {}, on rows {}-{}", plan.columns, @@ -1647,9 +1732,12 @@ fn write_font_sheet(path: &Path) { } /// Existing contributions from a saved `gh api graphql` response. -fn load(path: &PathBuf, grid: &Grid) -> BTreeMap { - let calendar = github::from_file(&path.to_string_lossy(), None) - .unwrap_or_else(|error| fail(&format!("could not read {path:?}: {error}"))); +fn load(path: &Path, grid: &Grid) -> BTreeMap { + // `from_file` names the path itself now, so re-prefixing would print it + // twice — and it used to quote the path where every other path message + // in these tools does not. + let calendar = + github::from_file(&path.to_string_lossy(), None).unwrap_or_else(|error| fail(&error)); let kept: BTreeMap = calendar .days() .filter(|day| day.count > 0 && grid.holds(day.date)) @@ -1776,6 +1864,24 @@ fn parse_args() -> Option { let mut args = Args::from_env("mossaic-art"); while let Some(arg) = args.next_arg() { + // Past a bare `--`, every argument is the text — checked before the + // flag names, or `-- --year` would still match the `--year` arm and + // `--` would mean "only the next one", which is nobody's convention. + if args.past_end_of_options() { + if options.text.is_empty() { + options.text = art::canonical(&arg).unwrap_or_else(|error| fail(&error)); + } else { + // The trap `--` sets for a first-time user: it means + // *everything* after it, as it does in `ls` and `git`, so + // options have to come first. Say that rather than leaving + // them to work it out. + fail(&format!( + "unexpected argument {arg:?} — everything after -- is the text, \ + so put the options before it" + )); + } + continue; + } match arg.as_str() { "-h" | "--help" => { println!("{HELP}"); @@ -1871,8 +1977,17 @@ fn parse_args() -> Option { tracking = args.next_arg(); } } - other if other.starts_with('-') => { - fail(&format!("unknown option {other:?} — try --help")) + // `is_positional` rather than a bare `starts_with('-')`: after a + // bare `--`, an argument that looks like a flag is text. The + // hyphen is a glyph the font draws and three documents list, and + // it could not be typed in the position they put it in. + other if !args.is_positional(other) => { + let hint = if options.text.is_empty() { + " — if that is the text, write it after --" + } else { + " — try --help" + }; + fail(&format!("unknown option {other:?}{hint}")) } other if options.text.is_empty() => { // Expanded here rather than at every use: the plan is saved @@ -1884,6 +1999,49 @@ fn parse_args() -> Option { } } + // The same rule, for the three flags that named a companion and were + // then ignored without it. #26 settled the principle in the maintainer's + // own words — "They are side effects and inputs the user explicitly + // asked for … Either honour them or refuse the combination" — and + // enumerated `--snapshot`, `--write` and `--file`, all three of which + // are refused above. These were missed. + // + // Refusing rather than honouring, deliberately: honouring `--png` would + // mean deciding what a PNG of a preview, a template list, a tracking + // report and a backfill each *are*, and `--snapshot` plus + // `mossaic --file --png` already covers the one people want. Refusing + // keeps that door open. + // + // The cost of the silence was the shape a script cannot see: + // `mossaic-art --template dragon --png preview.png` in a workflow printed + // a cheerful report, exited 0 and produced nothing, so the next step read + // a file that was not there — or, in a job that regenerates art, + // republished the previous run's stale PNG. + for (flag, ignored, needs, what) in [ + ( + "--png", + png_path.is_some() && !font, + "--font", + "writes the glyph sheet", + ), + ( + "-o", + options.output.is_some() && !options.draw, + "--draw", + "names the file the editor saves to", + ), + ( + "--format", + args.was_typed("--format") && !track, + "--track", + "chooses how the tracking report is written", + ), + ] { + if ignored { + fail(&format!("{flag} {what} — it needs {needs}")); + } + } + if list_templates { show_templates(options.colour.enabled()); return None; @@ -1951,7 +2109,6 @@ fn parse_args() -> Option { )); } } - if let Some(path) = &picture { let canvas = mossaic::image::load(path, image_options).unwrap_or_else(|error| fail(&error)); let name = canvas diff --git a/src/bin/mossaic-glyphs.rs b/src/bin/mossaic-glyphs.rs index e11b31e..7fc0120 100644 --- a/src/bin/mossaic-glyphs.rs +++ b/src/bin/mossaic-glyphs.rs @@ -42,6 +42,8 @@ Terminals that draw pixels never use these. `mossaic --capabilities` says whether yours does."; fn main() { + // Before anything prints: a reader that closes early is not a crash. + mossaic::quiet_broken_pipe(); // The shared parser, like the other two binaries. Parsing by hand made this // one disagree with them about everything a user notices: `--color=never` was // silently ignored, an unknown option exited 0, a stray argument was dropped, diff --git a/src/cli.rs b/src/cli.rs index 5f2a365..2b9582f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -26,6 +26,9 @@ pub struct Args { /// Long options that were actually typed, so a saved plan or a config can /// fill in the rest without overriding them. typed: Vec, + /// Set once a bare `--` has been read: everything after it is a + /// positional, whatever it looks like. + end_of_options: bool, } impl Args { @@ -51,19 +54,57 @@ impl Args { program, rest, typed: Vec::new(), + end_of_options: false, } } /// The next argument, whatever it is. Not `next`: this is not an iterator, /// and reading it as one would be a subtle way to lose an argument. + /// + /// A bare `--` is consumed here and switches the parser into + /// end-of-options: after it, [`Args::is_positional`] is true of + /// everything, so an argument that looks like a flag is text. pub fn next_arg(&mut self) -> Option { - self.rest.pop_front() + let arg = self.rest.pop_front()?; + if arg == "--" && !self.end_of_options { + self.end_of_options = true; + return self.rest.pop_front(); + } + Some(arg) + } + + /// Whether `arg` should be read as text rather than matched against the + /// option names. + /// + /// The hyphen is the case that forced this. `-` is a glyph the font + /// draws, README lists it first among the punctuation, `action.yml` + /// names it in the `text:` input description and the binary's own + /// no-glyph message prints it in the alphabet — and it could not be + /// typed in the position all three documents put it in. `mossaic-art -` + /// was `unknown option "-"`, and `--` was itself `unknown option "--"`, + /// so the escape hatch a user reaches for reported the escape hatch as + /// the mistake. No quoting helped; the only route through was a + /// hand-written plan JSON, which nothing documents. + /// + /// Deliberately *not* "it looks like text, so treat it as text": that + /// would turn a mistyped `--yaer 2027` into a drawing. + #[must_use] + pub fn is_positional(&self, arg: &str) -> bool { + self.end_of_options || !arg.starts_with('-') + } + + /// Whether a bare `--` has been read. + #[must_use] + pub fn past_end_of_options(&self) -> bool { + self.end_of_options } /// Whether an argument follows that is not itself an option — for the flags /// that take an optional value, like `--track [USER]`. pub fn peek_value(&self) -> bool { - self.rest.front().is_some_and(|next| !next.starts_with('-')) + self.rest + .front() + .is_some_and(|next| self.end_of_options || !next.starts_with('-')) } /// The value belonging to `flag`, or a readable exit. diff --git a/src/draw.rs b/src/draw.rs index ec39c1c..bb32a5b 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -222,6 +222,27 @@ impl Editor { }) } + /// How many days sit at each level, over the days the year actually has. + /// + /// `Canvas::histogram` counts *cells*, and the partial weeks at either + /// end are cells with no date behind them — the panel drew them as `·` + /// and told the user they "cost nothing", then counted them anyway. So + /// the rows read `level 4 1 day 4 commits each` four lines above + /// `0 commits in total`. `estimate()` has always filtered by `date_at`; + /// this is the same filter, for the rows beside it. + #[must_use] + pub fn histogram(&self) -> [usize; 5] { + let mut counts = [0usize; 5]; + for week in 0..self.canvas.width() { + for row in 0..CANVAS_ROWS { + if self.date_at(week, row).is_some() { + counts[usize::from(self.canvas.at(week, row)).min(4)] += 1; + } + } + } + counts + } + /// The two shades in the drawing that look most alike, and how far apart /// they are in the worst palette a reader might have. /// @@ -230,7 +251,11 @@ impl Editor { /// hold two shades nobody can separate. See [`art::Canvas::closest_pair`]. #[must_use] pub fn legibility(&self) -> Option<(u8, u8, Legibility, f32)> { - self.canvas.closest_pair() + // Over the shades that land inside the year, for the same reason the + // preview does: a drawing whose only ink is in the partial weeks + // draws nothing, and a verdict on it would be about cells nobody + // will see. + art::Canvas::closest_pair_of(&self.histogram()) } /// Handle a keystroke. @@ -496,7 +521,7 @@ pub fn render(frame: &mut Frame<'_>, editor: &Editor, palette: &Palette) { frame.render_widget(Paragraph::new(lines), body); // The numbers, which are the reason to draw here rather than in an editor. - let histogram = editor.canvas.histogram(); + let histogram = editor.histogram(); let peak = editor.peak(); let mut stats: Vec> = Vec::new(); stats.push(Line::from(match editor.cursor_date() { @@ -515,7 +540,8 @@ pub fn render(frame: &mut Frame<'_>, editor: &Editor, palette: &Palette) { for level in (0..=4u8).rev() { let count = histogram[usize::from(level)]; let rgb = palette.levels[usize::from(level)]; - let bar = "█".repeat(count * 30 / (editor.canvas.width() * CANVAS_ROWS).max(1)); + let total: usize = histogram.iter().sum(); + let bar = "█".repeat(count * 30 / total.max(1)); stats.push(Line::from(vec![ Span::raw(format!( " level {level} {count:>4} {:<6} ", @@ -525,10 +551,14 @@ pub fn render(frame: &mut Frame<'_>, editor: &Editor, palette: &Palette) { Span::raw(if level == 0 { " (must stay dark)".to_string() } else { - format!( - " {} commits each", - thousands(art::commits_to_reach(level, peak)) - ) + { + let each = art::commits_to_reach(level, peak); + format!( + " {} {} each", + thousands(each), + crate::plural(each, "commit", "commits") + ) + } }), ])); } diff --git a/src/github.rs b/src/github.rs index 60d1e05..e98938f 100644 --- a/src/github.rs +++ b/src/github.rs @@ -35,7 +35,7 @@ pub fn fetch(login: &str, year: i32, today: NaiveDate) -> Result) -> Result now.map_or_else(|| Local::now().year(), |date| date.year()), &body, now, + Source::File(path), ) } @@ -92,10 +93,58 @@ fn run_query(vars: &[(&str, &str)]) -> Result { Ok(stdout) } +/// Where a response came from, so an error can name the right thing. +/// +/// `--file` and the network share one parser, and the parser's only wording +/// was "unexpected response from gh". So a truncated local file the user +/// wrote themselves was reported as their GitHub CLI returning something +/// odd — on a run where `gh` was never executed — and `{"data":{}}` was +/// reported as GitHub having no such login. They then checked +/// `gh auth status`, the username and the network: everything except the +/// JSON in front of them. +/// +/// `--file` is also the flag most likely to be handed a file another +/// process is still writing, which is exactly the truncated case. The plan +/// loader two commands away already gets this right. +#[derive(Debug, Clone, Copy)] +enum Source<'a> { + /// The `gh` subprocess. + Gh, + /// A file the user named. + File(&'a str), +} + +impl Source<'_> { + /// "…is not a saved contributions response: ", or the gh wording. + fn not_readable(self, detail: &str) -> String { + match self { + Source::Gh => format!("unexpected response from gh: {detail}"), + Source::File(path) => { + format!("{path} is not a saved contributions response: {detail}") + } + } + } + + /// The document parsed but holds no user. + fn no_user(self) -> String { + match self { + Source::Gh => "GitHub returned no user for that login".to_string(), + Source::File(path) => { + format!("{path} holds no user — a saved response has data.user") + } + } + } +} + /// `now` decides which days count as still to come; `None` means none of them do. -fn parse(fallback_year: i32, body: &str, now: Option) -> Result { +fn parse( + fallback_year: i32, + body: &str, + now: Option, + source: Source<'_>, +) -> Result { let resp: Response = - serde_json::from_str(body).map_err(|e| format!("unexpected response from gh: {e}"))?; + serde_json::from_str(body).map_err(|e| source.not_readable(&e.to_string()))?; // Everything below this line came from somewhere else, so it is stripped of // control characters before it can reach a terminal. @@ -111,7 +160,7 @@ fn parse(fallback_year: i32, body: &str, now: Option) -> Result() + .map(String::as_str) + .or_else(|| info.payload().downcast_ref::<&str>().copied()) + .unwrap_or_default(); + let printing = payload.starts_with("failed printing to stdout") + || payload.starts_with("failed printing to stderr"); + if printing && payload.contains("Broken pipe") { + // The reader got what it wanted and hung up. Nothing is left to + // say and nothing went wrong. + std::process::exit(0); + } + previous(info); + })); +} + +/// Print `text` to stdout, treating a reader that has gone as a clean exit. +/// +/// Rust's runtime sets `SIGPIPE` to `SIG_IGN`, so `println!` into a closed +/// pipe panics and the process exits **101** — a code outside the set these +/// binaries document, over a message nobody was listening to, with a +/// backtrace note that reads like a crash in mossaic rather than the user +/// closing a pager. `mossaic-art --font | less` and quitting, +/// `--list-templates | head`, `--format json | jq -e …` with a jq that exits +/// early: all ordinary, all a panic. +/// +/// Which commands escaped was a pipe-buffer accident — the coloured glyph +/// sheet is 53 KB and always panicked, the uncoloured one is 9 KB and +/// survived on Linux but not macOS — so it read as a flake. +/// +/// The `--png` case is the one with a price: a valid, complete PNG is +/// already on disk and the caller was handed 101, so a wrapper that checks +/// the status deletes the file and retries. Exiting **0** there is the +/// correct answer, and is why this is a write-side check rather than +/// restoring the default `SIGPIPE` disposition — which `unsafe_code = +/// "forbid"` bars anyway. +/// +/// Any other write failure is a real one and goes through the caller's own +/// `fail`. +pub fn print_or_exit(text: &str) { + use std::io::Write; + let mut out = std::io::stdout(); + match out.write_all(text.as_bytes()).and_then(|()| out.flush()) { + Ok(()) => {} + // `| head -1`, `| grep -m1`, a pager the user quit: the reader got + // what it wanted and hung up. There is nothing left to say and + // nothing went wrong. + Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => std::process::exit(0), + Err(e) => { + eprintln!("mossaic: cannot write to stdout: {e}"); + std::process::exit(2); + } + } +} + +/// [`print_or_exit`] with a trailing newline, for the `println!` shape. +pub fn println_or_exit(text: &str) { + print_or_exit(&format!("{text}\n")); +} diff --git a/src/main.rs b/src/main.rs index e4022e5..4587af0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -88,6 +88,8 @@ struct Invocation { } fn main() { + // Before anything prints: a reader that closes early is not a crash. + mossaic::quiet_broken_pipe(); let Some(invocation) = parse_args() else { return; }; @@ -129,7 +131,7 @@ fn main() { // screen where the first frame paints over it. let mut app = App::new(login, year, source); app.configure(term::probe(PROBE), options); - restore_mouse_on_panic(); + mossaic::restore::guard_terminal(); let outcome = run(&mut terminal, &mut app); let restored = ratatui::try_restore(); @@ -264,16 +266,6 @@ fn report_capabilities(options: Options) { println!("cells {}", app.protocol_name()); } -/// A panic that unwinds past the event loop would otherwise leave mouse reporting -/// on, and the shell printing escape codes at every click. -fn restore_mouse_on_panic() { - let previous = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - let _ = execute!(io::stdout(), DisableMouseCapture); - previous(info); - })); -} - /// Returns `None` when there is nothing left to run, e.g. after printing help. fn parse_args() -> Option { let mut login = None; @@ -289,6 +281,23 @@ fn parse_args() -> Option { let mut args = Args::from_env("mossaic"); while let Some(arg) = args.next_arg() { + // Past a bare `--`, every argument is the login. See the same block + // in mossaic-art: a dash-led login has the same hole. + if args.past_end_of_options() { + if login.is_none() { + login = Some(arg); + } else { + // The trap `--` sets for a first-time user: it means + // *everything* after it, as it does in `ls` and `git`, so + // options have to come first. Say that rather than leaving + // them to work it out. + fail(&format!( + "unexpected argument {arg:?} — everything after -- is the login, \ + so put the options before it" + )); + } + continue; + } match arg.as_str() { "-h" | "--help" => { println!("{HELP}"); @@ -352,8 +361,15 @@ fn parse_args() -> Option { "--cell" => options.cell = Some(parse_cell(&args.value("--cell"))), "--png" => png = Some(args.value("--png")), "--capabilities" => capabilities = true, - other if other.starts_with('-') => { - fail(&format!("unknown option {other:?} — try --help")) + // The same hole, in the same shared parser: `mossaic -- -weirdlogin` + // reported `--` as the unknown option. + other if !args.is_positional(other) => { + let hint = if login.is_none() { + " — if that is the login, write it after --" + } else { + " — try --help" + }; + fail(&format!("unknown option {other:?}{hint}")) } other => login = Some(other.to_string()), } diff --git a/src/plan.rs b/src/plan.rs index c3de168..2116e9d 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -553,7 +553,24 @@ pub fn contributions(calendar: &crate::calendar::Calendar) -> BTreeMap Result { let body = std::fs::read_to_string(path) .map_err(|error| format!("could not read {}: {error}", path.display()))?; - let spec: Self = serde_json::from_str(&body) - .map_err(|error| format!("{} is not a mossaic plan: {error}", path.display()))?; + let spec: Self = serde_json::from_str(&body).map_err(|error| { + // serde names the key it did not know and lists the ones it does, + // which is the whole of what a typo needs. + format!("{} is not a mossaic plan: {error}", path.display()) + })?; // Named as a problem with the file, and with the way out of it: 0.2.0 // let `--commits -1` through, which `--save` then wrote down as four // billion, so a plan in the wild can be one this refuses. Saving it @@ -942,14 +962,38 @@ impl Report { /// A summary that reads the same in a GitHub step summary, a Slack message, /// a Discord embed and an email — the four places this ends up. pub fn markdown(&self) -> String { - let mut out = format!("### {} · {}\n\n", self.text, self.year); + // The placement, in the header. action/README.md told readers "the + // report prints the placement it used on its second line; if that + // ever changes, so did your plan" — and no line in either format + // carried it. That sentence is the only guard the project offers + // against the one failure it calls silent and confident, and the + // consumer adopted it as its stated safety net: changing + // `start-week` only moved the numbers, which legitimately change + // every day anyway, so a changed plan was indistinguishable from a + // normal day's drift. + let mut out = format!( + "### {} · {} · week {}, {} {}\n\n", + self.text, + self.year, + self.start_week, + self.columns, + crate::plural(self.columns, "column", "columns") + ); out.push_str(&match self.verdict { "drawn" => format!("**{} is drawn.**\n\n", self.text), "holed" => format!( - "**Cannot be drawn cleanly** — {} {} inside the letters already \ - lit, and nothing takes those away.\n\n", + // The noun, not the verb. 0.6.3 moved the verb in front of + // "inside the letters", which turned "already lit" into a + // reduced relative clause on *the letters* — the sentence + // said the letters were lit rather than the days. The text + // renderer's equivalent still read correctly, so only the + // path the Action publishes regressed. + "**Cannot be drawn cleanly** — {} {} inside the letters {} already \ + lit, and nothing takes {} away.\n\n", self.holes, - plural(self.holes, "day is", "days are") + plural(self.holes, "day", "days"), + plural(self.holes, "is", "are"), + plural(self.holes, "it", "them") ), _ => format!( "**On track** — {} {} to go{}.\n\n", diff --git a/src/render_tests.rs b/src/render_tests.rs index cdca357..085d762 100644 --- a/src/render_tests.rs +++ b/src/render_tests.rs @@ -2449,6 +2449,119 @@ fn control_characters_never_leave_the_parser() { assert_eq!(crate::printable("héllo ✓"), "héllo ✓", "text is left alone"); } +/// The header counts what happened, not what the year will hold. +/// +/// Every figure on the chart honoured `--today` except the biggest one. It +/// took `Calendar::total` — GitHub's number for the whole Jan-1..Dec-31 +/// range — while everything below came from `elapsed()`, so reading the +/// shipped calendar as of March printed December's 9,527 above a grid +/// showing 2,043 and beside "23 active days": a 4.7x overstatement of the +/// one figure a screenshot carries. +/// +/// Asserted against the *footer*, so the test cannot be satisfied by a +/// header that is merely different — the two have to agree. +#[test] +fn the_header_agrees_with_the_footer_when_a_year_is_read_as_of_a_day() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("art/vyncint-2026.json"); + let Ok(calendar) = crate::github::from_file( + path.to_str().unwrap(), + Some(NaiveDate::from_ymd_opt(2026, 3, 31).unwrap()), + ) else { + panic!("the shipped calendar loads"); + }; + let elapsed: u32 = calendar.elapsed().map(|day| day.count).sum(); + let total = calendar.total; + assert_ne!(elapsed, total, "the fixture must have future days to test"); + + let mut app = ready(calendar); + let frame = render(&mut app, 170, 22); + let wanted = format!("{} contributions in 2026", crate::thousands(elapsed)); + assert!( + frame.contains(&wanted), + "the header must count the elapsed days ({wanted}):\n{frame}" + ); + assert!( + !frame.contains(&format!( + "{} contributions in 2026", + crate::thousands(total) + )), + "and must not print the whole year's figure over a partial grid" + ); + + // A finished year is unchanged: `total` is GitHub's own figure and can + // legitimately exceed the sum of visible days, which is why the switch + // is on `any(future)` rather than on a comparison. + let whole = crate::github::from_file( + path.to_str().unwrap(), + Some(NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()), + ) + .expect("the same file, read whole"); + let mut app = ready(whole); + let frame = render(&mut app, 170, 22); + assert!( + frame.contains(&format!( + "{} contributions in 2026", + crate::thousands(total) + )), + "a finished year still shows GitHub's own total:\n{frame}" + ); +} + +/// GHSA-jp9f-97rv-j4hx. +/// +/// The calendar path was cleaned in the 0.1.0 review; the `.art` header was +/// not, and a `.art` file is the one thing this project asks strangers to +/// send. A reviewer running `mossaic-art --matrix theirs.art` or +/// `--list-templates` executed whatever the header said: a window-title +/// change, an `OSC 52` clipboard write, or a cursor-position query whose +/// reply is typed back into the shell once the tool exits. `build.rs` embeds +/// `art/templates/*.art`, so a merged template shipped its payload to every +/// user on every listing. +#[test] +fn control_characters_never_leave_an_art_header() { + let evil = "\u{1b}]0;PWNED\u{7}\u{1b}[31mEVIL\u{1b}[0m"; + let source = format!( + "# name: {evil}\n# author: {evil}\n# description: {evil}\n\ + 0100010\n0010100\n0001000\n0010100\n0100010\n0000000\n0000000\n" + ); + let canvas = crate::art::Canvas::parse(&source).expect("the body is a valid canvas"); + let meta = canvas.meta(); + for (label, field) in [ + ("name", meta.name.as_deref()), + ("author", meta.author.as_deref()), + ("description", meta.description.as_deref()), + ] { + let field = field.unwrap_or_else(|| panic!("{label} is read")); + assert!( + !field.chars().any(char::is_control), + "{label} still carries control characters: {field:?}" + ); + assert!( + field.contains("PWNED"), + "the text itself is harmless: {field:?}" + ); + } + + // A bare carriage return is enough to overwrite what was shown, and does + // not look like an escape sequence to anyone reading the file. + let source = "# name: SAFE\rEVIL\n0100010\n0010100\n0001000\n0010100\n\ + 0100010\n0000000\n0000000\n"; + let canvas = crate::art::Canvas::parse(source).unwrap(); + assert_eq!(canvas.meta().name.as_deref(), Some("SAFEEVIL")); + + // And a header is bounded: unbounded, a 200,000-character name produced a + // 200,061-byte first output line and rode through `--save` into the plan. + let long = format!( + "# name: {}\n0100010\n0010100\n0001000\n0010100\n0100010\n0000000\n0000000\n", + "A".repeat(200_000) + ); + let canvas = crate::art::Canvas::parse(&long).unwrap(); + assert!( + canvas.meta().name.as_deref().unwrap().chars().count() <= 200, + "a header field must be bounded" + ); +} + #[test] fn a_calendar_cannot_span_more_than_a_year() { // Two dates millennia apart used to size the grid by the distance between diff --git a/src/restore.rs b/src/restore.rs new file mode 100644 index 0000000..592bb20 --- /dev/null +++ b/src/restore.rs @@ -0,0 +1,140 @@ +//! Putting the terminal back, on every way out. +//! +//! A TUI borrows the terminal: the alternate screen, raw mode, mouse +//! reporting. The borrow has to be returned on **every** exit, and there are +//! four — the ordinary one, a panic, a signal, and a write that fails +//! because the reader has gone. +//! +//! Before 0.7.0 two of the four were covered. `main` restored on the way out +//! and installed a panic hook that says why in as many words: "A panic that +//! unwinds past the event loop would otherwise leave mouse reporting on, and +//! the shell printing escape codes at every click." A **signal** reached the +//! same state by a path with no guard at all: under a pty, `kill -INT` on +//! either binary emitted zero bytes to the tty, leaving the shell inside the +//! alternate screen with mouse tracking on, ECHO, ICANON and ISIG off, and +//! no working Ctrl-C. The way out is to type `reset` blind, which a +//! first-time user does not know, and nothing on screen says so — the +//! failure is in what was *not* written. +//! +//! Whoever reaches for a signal is already having a bad time: the chart is +//! sitting on "loading" because `gh` is slow, so they `kill %1` from another +//! pane, or they wrapped it in `timeout`, or a script signalled the process +//! group. +//! +//! ## Why `signal-hook` rather than `libc::sigaction` +//! +//! `Cargo.toml` is `unsafe_code = "forbid"`, and `SECURITY.md` advertises +//! that posture ("the one libc dependency is used for a single constant, not +//! a call"). `sigaction` is an unsafe call, so the obvious route is barred. +//! +//! `signal-hook` registers safely and — the part that matters — is *already +//! in the tree*: crossterm pulls it through ratatui for its event stream, at +//! the same version pinned here. So this adds an import, not a dependency: +//! no new third-party code, no new licence to review, no new supply chain. +//! +//! The work happens on a thread rather than in a handler, which is what +//! makes it safe to do anything at all: a real signal handler may call only +//! async-signal-safe functions, and writing escape sequences through Rust's +//! stdout is not one of them. + +use std::io; + +use ratatui::crossterm::cursor::Show; +use ratatui::crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use ratatui::crossterm::execute; + +/// Give the terminal back: mouse reporting off, alternate screen off, raw +/// mode off, cursor shown. +/// +/// Best-effort by construction. This runs on the way out of a process that +/// may already be failing, and on a terminal that may already have gone; an +/// error here must not mask the reason we are leaving. +pub fn terminal() { + // Mouse first: `try_restore` leaves the alternate screen, and a mouse + // report arriving after that lands on the shell's own screen. + let _ = execute!(io::stdout(), DisableMouseCapture); + let _ = ratatui::try_restore(); + // Explicitly, and last. Leaving the alternate screen restores that + // screen's cursor state, which is not necessarily the shell's: a hidden + // cursor is the one piece of this a user cannot see is missing, and + // cannot guess the cure for. + let _ = execute!(io::stdout(), Show); +} + +/// Restore the terminal on a panic, then let the previous hook run. +/// +/// Kept beside [`on_signal`] because they are the same obligation: the two +/// exits nobody writes code for. +pub fn on_panic() { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + terminal(); + previous(info); + })); +} + +/// Restore the terminal on `SIGINT`, `SIGTERM` and `SIGHUP`, then die of the +/// signal. +/// +/// Re-raising with the default disposition matters: a process killed by a +/// signal must still *report* as killed by that signal, or a shell's `$?`, a +/// `timeout` wrapper and a supervisor all learn the wrong thing about why it +/// stopped. `emulate_default_handler` does exactly that. +/// +/// Ctrl-C *typed into* a TUI is a key, handled by the event loop, and does +/// not come through here — the damage always needed an actual signal. +/// +/// Failing to register is not worth failing the run over: the terminal is +/// no worse off than it was before 0.7.0, and the user asked to draw a +/// chart, not to install a signal handler. +#[cfg(unix)] +pub fn on_signal() { + use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM}; + let Ok(mut signals) = signal_hook::iterator::Signals::new([SIGINT, SIGTERM, SIGHUP]) else { + return; + }; + std::thread::spawn(move || { + for signal in signals.forever() { + terminal(); + // Unregisters our handler and re-raises, so the exit status is + // death-by-signal rather than a plain code. + let _ = signal_hook::low_level::emulate_default_handler(signal); + } + }); +} + +/// No-op off Unix. +/// +/// Windows has no POSIX signals: a console application is torn down through +/// a control handler with a different lifetime and a different contract, and +/// pretending otherwise here would mean claiming a guarantee that is not +/// installed. The panic hook covers the other unguarded exit on every +/// platform. +#[cfg(not(unix))] +pub fn on_signal() {} + +/// Everything a TUI owes the terminal, installed in one call. +/// +/// Both binaries take the alternate screen and both enable mouse reporting, +/// so both need all three guards. `mossaic-art --draw` had none of them. +pub fn guard_terminal() { + on_panic(); + on_signal(); +} + +/// Turn mouse reporting on, for a view that reads the pointer. +/// +/// Here rather than at the call sites so that the enable and the disable are +/// written next to each other and cannot drift apart. +/// +/// # Errors +/// +/// Propagates the write to stdout. +pub fn capture_mouse(on: bool) -> io::Result<()> { + let mut out = io::stdout(); + if on { + execute!(out, EnableMouseCapture) + } else { + execute!(out, DisableMouseCapture) + } +} diff --git a/src/templates.rs b/src/templates.rs index fff3d3f..5419af0 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -103,7 +103,7 @@ pub fn catalogue() -> Vec