Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,6 @@ jobs:
name: Coverage
runs-on: ubuntu-latest
needs: gate
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
Expand Down Expand Up @@ -199,6 +197,31 @@ jobs:
run: |
cargo tarpaulin --all-features --out xml --output-dir target/tarpaulin -- --test-threads=1 2>&1 | tee /tmp/tarpaulin.log

- name: Upload coverage report for dev badge
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: target/tarpaulin/cobertura.xml
if-no-files-found: error
retention-days: 1

coverage-badge:
name: Update coverage badge
runs-on: ubuntu-latest
needs: coverage
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: dev
- name: Download coverage report
uses: actions/download-artifact@v4
with:
name: coverage-report
path: target/tarpaulin
- name: Write coverage badge in place
run: |
raw="$(grep -oE 'line-rate="[0-9.]+' target/tarpaulin/cobertura.xml | head -1 | grep -oE '[0-9.]+')"
Expand All @@ -215,12 +238,12 @@ jobs:
esac
curl -sS "https://img.shields.io/badge/coverage-${pct}%25-${color}.svg" -o coverage.svg
# Commit the badge in place only when it changed (git diff ignores
# untracked files, so use status), so the push cannot re-trigger CI
# indefinitely.
# untracked files, so use status). The skip marker prevents this
# trusted publication commit from starting another CI run.
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if [ -n "$(git status --porcelain -- coverage.svg)" ]; then
git add coverage.svg
git commit -q -m "docs: update coverage badge ($pct%)"
git push -q origin dev
git commit -q -m "docs: update coverage badge ($pct%) [skip ci]"
git push -q origin HEAD:dev
fi
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
Cargo.lock

# Local artifacts
.codemap/
/scripts/failures
/tmp
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name = "rusty-bubbletea"
exclude = ["POLICIES.md"]
version = "2.0.8"
edition = "2021"
rust-version = "1.91"
description = "Cleanroom Rust port of Charmbracelet's Bubble Tea (v2.0.8) TUI Elm architecture framework"
license = "MIT"

Expand Down
21 changes: 11 additions & 10 deletions UPSTREAM_MAPPING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ upstream tag `v2.0.8`, checked out locally in `upstream-go/` (gitignored).

| Upstream Go File | Rust Equivalent / Status | Notes / Description |
| :--- | :--- | :--- |
| `tea.go` | `src/lib.rs`, `src/view.rs`, `src/program.rs` | Core Elm architecture: `Model`, `Msg`, `Cmd`, `Program`, `View` |
| `tea_test.go` | `tests/tea_test.rs` | Core program unit tests |
| `tea.go` | `src/lib.rs`, `src/view.rs`, `src/program.rs` | Core Elm architecture: `Model`, `Msg`, `Cmd`, `Program`, `View`; `Program` owns one-shot lifecycle state, external handles, configured I/O, cancellation, panic recovery, and renderer cleanup |
| `tea_test.go` | `tests/tea_test.rs` | Core program unit tests, including startup contract, headless lifecycle, cancellation, panic recovery, handle cleanup, and protocol-output ordering |
| `clipboard.go` | `src/clipboard.rs` | OSC52 clipboard ops (`set_clipboard`, `read_clipboard`, `ClipboardMsg`) |
| `color.go` | `src/color.rs` — **Refactored** | Response messages wrap `rusty-ultraviolet` color events; `is_dark` via the upstream HSL logic | Color requests and messages (`request_background_color`, `BackgroundColorMsg`, …) |
| `commands.go` | `src/commands.rs` | Built-in commands (`quit`, `batch`, `sequence`, `tick`, `every`, `request_window_size`) |
| `commands.go` | `src/commands.rs` | Built-in commands (`quit`, `batch`, `sequence`, `tick`, `every`, `request_window_size`); no-op commands are removed while singleton command behavior remains deterministic |
| `commands_test.go` | `tests/commands_test.rs` | Command suite tests |
| `cursed_renderer.go` | `src/cursed_renderer.rs` | CursedRenderer: declarative view frames, ANSI diffing, unmanaged lines |
| `cursed_renderer.go` | `src/cursed_renderer.rs` | CursedRenderer: declarative view frames, ANSI diffing, unmanaged lines, and direct protocol output ahead of buffered frames |
| `cursed_renderer_test.go` | `tests/tea_test.rs` | Renderer tests |
| `cursor.go` | `src/cursor.rs` | Cursor position/shape, `request_cursor_position` |
| `environ.go` | `src/environ.rs` | `EnvMsg` environment variables |
Expand All @@ -33,12 +33,12 @@ upstream tag `v2.0.8`, checked out locally in `upstream-go/` (gitignored).
| `mouse.go` | `src/mouse.rs` | `MouseButton`, `Mouse`, typed mouse messages |
| `mouse_test.go` | `tests/mouse_test.rs` | Mouse suite tests |
| `nil_renderer.go` | `src/nil_renderer.rs` | No-op renderer |
| `options.go` | `src/options.rs` | `ProgramOptions` constructors |
| `options.go` | `src/options.rs` | `ProgramOptions` constructors; explicit input disabling is tracked separately from default stdin and FPS is normalized to the documented 60–120 bounds |
| `options_test.go` | `tests/commands_test.rs` | Option tests |
| `paste.go` | `src/paste.rs` | Bracketed paste messages |
| `profile.go` | `src/profile.rs` | `ColorProfileMsg` |
| `raw.go` | `src/raw.rs` | `raw` command sending ANSI sequences |
| `renderer.go` | `src/renderer.rs` | `Renderer` trait |
| `renderer.go` | `src/renderer.rs` | `Renderer` trait, including the direct protocol-output hook used before buffered frame flushes |
| `screen.go` | `src/screen.rs` | `WindowSizeMsg`, `clear_screen`, `ModeReportMsg` |
| `screen_test.go` | `tests/commands_test.rs` | Screen tests |
| `signals_unix.go` | `src/signals_unix.rs` | SIGWINCH resize listener |
Expand Down Expand Up @@ -170,14 +170,15 @@ in the Support Files section.
| :--- | :--- | :--- |
| `LICENSE` | `LICENSE` | MIT License (matching upstream copyright) |
| `README.md` | `README.md` | Documented Rust port header with graphics & links |
| Repository lifecycle guide | `docs/src/lib.rs` | User-facing documentation anchor for `ProgramHandle`, headless options, cancellation, and graceful versus error shutdown |
| `UPGRADE_GUIDE_V2.md` | `README.md` (notes) | v1 -> v2 migration guidance summarized in README |
| `go.mod` / `go.sum` | `Cargo.toml` | Dependency manifest (Go modules -> Cargo crates) |
| `go.mod` / `go.sum` | `Cargo.toml` | Dependency manifest (Go modules -> Cargo crates); candidate declares the supported Rust 1.91 toolchain floor |
| `examples/go.mod` / `examples/go.sum` / `tutorials/go.mod` / `tutorials/go.sum` | `Cargo.toml` | Example-module manifests (deps like bubbles, glamour, harmonica are example-only) |
| `examples/*/README.md` and `examples/*/*.gif` | `examples/` docs | Per-example docs/assets; retained as upstream documentation references |
| `examples/isbn-form/isbn-form.tape` | (asset) | VHS recording asset; not applicable to the Rust crate |
| `examples/table/demo.tape` | (asset) | VHS recording asset; not applicable to the Rust crate |
| `Taskfile.yaml` / `.goreleaser.yml` / `.golangci.yml` | `.github/workflows/publish.yml` | Build/lint/release config -> CI workflow |
| `.github/workflows/*` | `.github/workflows/publish.yml` | CI/CD -> Rust publish workflow + example parity check |
| `.github/workflows/*` | `.github/workflows/publish.yml` | CI/CD -> Rust CI/publish workflows, example parity, and trusted default-branch badge publication |
| `.github/ISSUE_TEMPLATE/*` / `.github/dependabot.yml` / `.gitattributes` / `.gitignore` / `.editorconfig` | `.gitignore` | Process/config files; not applicable to the Rust crate |
| `testdata/*.golden` | `tests/*.rs` | Golden outputs accounted for by test assertions |

Expand Down Expand Up @@ -205,6 +206,6 @@ in the Support Files section.
- Port-wide fixes required for parity: kitty-bitset `KeyMod` constants, SGR emission order
(colors before attrs, 39/49/59 default-color resets, attr reset codes 22/23/24/25/27/8/29),
pen reset before pending spaces in `renderLine`, go-exact `Duration::String()`, the color
profile applied to the renderer (env-detect + ColorProfileMsg), OSC queries buffered and
flushed with the first render (ticker-only), final model render on graceful quit, and the
profile applied to the renderer (env-detect + ColorProfileMsg), protocol and OSC queries
emitted ahead of buffered renderer startup output, final model render on graceful quit, and the
start-up message burst (WindowSizeMsg + EnvMsg + ColorProfileMsg) matching upstream.
21 changes: 21 additions & 0 deletions docs/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//! Bubble Tea lifecycle and configuration guide.
//!
//! <user-docs>
//! # Running a Program
//!
//! [`rusty_bubbletea::Program`] owns a model's event loop. Configure terminal
//! dimensions, environment, input, output, color profile, and cancellation
//! through [`rusty_bubbletea::ProgramOptions`]. For a runner moved to another
//! thread, obtain [`rusty_bubbletea::ProgramHandle`] before calling
//! [`rusty_bubbletea::Program::run`]. The handle can queue messages, request a
//! graceful `quit`, request an error `kill`, and wait for renderer cleanup.
//!
//! `with_input(None)` creates a deterministic headless program by disabling
//! input. `without_renderer()` selects the no-op renderer. A graceful quit
//! renders and flushes the final model view; cancellation, interruption, and
//! kill skip that final frame while still restoring terminal state.
//! </user-docs>
//!
//! Maintainer note: this file is the documentation anchor for the public
//! lifecycle contract. Implementation details belong in `src/program.rs` and
//! option semantics belong in `src/options.rs`.
59 changes: 59 additions & 0 deletions evidence/acceptance/BUI-011/independent-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# BUI-011 Independent Review

Status: implementation self-review complete; protected pull-request review and
exact-head CI remain the independent merge gate.

## Scope

- `src/program.rs`: one-shot lifecycle state, `ProgramHandle`, configured
renderer/input setup, startup messages, cancellation, panic recovery, render
ticker shutdown, and graceful/error cleanup.
- `src/renderer.rs` and `src/cursed_renderer.rs`: direct protocol-output
path for queries that must precede buffered renderer startup output.
- `src/options.rs`: explicit headless input sentinel and FPS normalization.
- `src/commands.rs`: no-op command filtering and singleton command behavior.
- `tests/tea_test.rs`: focused lifecycle, startup, cancellation, panic, FPS,
command-shape and protocol-output-order regressions.
- `docs/src/lib.rs`: public lifecycle and headless-configuration guidance.
- `.github/workflows/ci.yml`: protected coverage reporting and trusted
default-branch badge publication.

## Review checks

| Check | Result | Evidence |
| --- | --- | --- |
| Lifecycle has one runner, observable cleanup, graceful quit, kill, interruption, cancellation, and panic paths | Pass | `src/program.rs`; focused lifecycle tests |
| Headless execution avoids raw input setup and honors configured startup values | Pass | `tests/tea_test.rs::test_program_uses_configured_startup_contract` |
| Command no-op filtering preserves empty and singleton semantics | Pass | `tests/tea_test.rs::test_commands_and_messages` |
| Protocol queries precede buffered renderer startup output | Pass | `tests/tea_test.rs::test_protocol_query_precedes_buffered_renderer_startup_output`; targeted PTY parity |
| Documentation uses the current `<user-docs>` contract | Pass | `src/program.rs`, `src/options.rs`, `src/commands.rs`, `docs/src/lib.rs` |
| Focused Rust validation | Pass | `cargo check -p rusty-bubbletea --lib`; `cargo test -p rusty-bubbletea --test tea_test --no-fail-fast`; 14 tests passed |
| Reported protected parity failures | Pass locally | 14 reported examples matched Go traces after the protocol-output fix; the timing-sensitive `send-msg` trace matched across six repeated runs |
| Coverage workflow keeps pull-request candidate jobs read-only | Pass locally | Coverage report upload is push-only; badge commit/push is isolated to a trusted `dev` push job using `HEAD:dev` |

## Findings and limits

The first protected CI attempt exposed a protocol ordering defect in
`verify_examples`: synchronized-output and terminal-color queries were written into the renderer frame buffer after startup control
sequences. The renderer now has a direct protocol-output path, and the focused regression plus targeted PTY parity checks pass.
The second protected CI attempt (run 32947899448) then exposed a workflow
publication defect: coverage reached 74.04%, but the PR merge checkout had no
local `dev` ref and the badge step failed with `src refspec dev does not
match any`. Coverage now keeps `contents: read` for candidate pull-request
execution, uploads the report only on a trusted `dev` push, and performs badge
publication in a push-only job with `HEAD:dev` and `[skip ci]` to avoid
recursive validation. The corrected workflow awaits a new protected exact-head
run.
The worktree cannot run the untouched full dependency graph because the
`rusty-bubbles` dev dependency resolves its `../rusty-bubbletea` path to the
primary checkout, causing Cargo's package-collision error when the isolated
worktree is present. Focused validation therefore temporarily omitted that
dev-only dependency and restored the manifest immediately after each run; the
feature diff retains the original dependency line.

The manifest now declares `rust-version = "1.91"`, matching the workspace
toolchain policy and making the candidate's supported compiler floor explicit.

This record is implementation evidence, not an approval or merge
authorization. The final independent review, protected checks, and aggregate
acceptance remain owned by the repository and Mutate release gates.
32 changes: 16 additions & 16 deletions src/commands.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
//! Cleanroom Rust port of upstream Go source file: `commands.go`
//! Upstream Target Tag / Version: `v2.0.8`
//!
//! <public-docs>
//! <user-docs>
//! # Commands
//!
//! Built-in command functions (`batch`, `sequence`, `every`, `tick`, `request_window_size`).
//! </public-docs>
//! </user-docs>
//!
//! Maintainer note: command trees are values until the program event loop
//! executes them. `batch` preserves concurrent execution, while `sequence`
//! preserves source order, including for nested command trees.

use crate::model::{Cmd, Msg};
use std::fmt;
Expand Down Expand Up @@ -76,35 +80,31 @@ pub fn interrupt() -> Cmd {
/// Batch performs a bunch of commands concurrently with no ordering guarantees
/// about the results. Use `batch` to return several commands.
pub fn batch(cmds: Vec<Cmd>) -> Cmd {
let mut valid_cmds = Vec::new();
for cmd in cmds {
if cmd.is_some() {
valid_cmds.push(cmd);
}
}
let valid_cmds = retain_commands(cmds);
match valid_cmds.len() {
0 => None,
1 => valid_cmds.into_iter().next().unwrap(),
1 => valid_cmds.into_iter().next().flatten(),
_ => Some(Box::new(move || Some(Box::new(BatchMsg(valid_cmds))))),
}
}

/// Sequence runs the given commands one at a time, in order. Contrast this with
/// `batch`, which runs commands concurrently.
pub fn sequence(cmds: Vec<Cmd>) -> Cmd {
let mut valid_cmds = Vec::new();
for cmd in cmds {
if cmd.is_some() {
valid_cmds.push(cmd);
}
}
let valid_cmds = retain_commands(cmds);
match valid_cmds.len() {
0 => None,
1 => valid_cmds.into_iter().next().unwrap(),
1 => valid_cmds.into_iter().next().flatten(),
_ => Some(Box::new(move || Some(Box::new(SequenceMsg(valid_cmds))))),
}
}

/// Drops no-op commands while retaining the command container shape expected
/// by the event-loop executor.
fn retain_commands(cmds: Vec<Cmd>) -> Vec<Cmd> {
cmds.into_iter().filter(|cmd| cmd.is_some()).collect()
}

/// Every is a command that ticks in sync with the system clock. So, if you
/// wanted to tick with the system clock every second, minute or hour you
/// could use this. It's also handy for having different things tick in sync.
Expand Down
7 changes: 7 additions & 0 deletions src/cursed_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,13 @@ impl Renderer for CursedRenderer {
Ok(n)
}

fn write_direct(&mut self, s: &str) -> Result<usize, Box<dyn std::error::Error>> {
let bytes = s.as_bytes();
self.w.write_all(bytes)?;
self.w.flush()?;
Ok(bytes.len())
}

fn on_mouse(&mut self, m: MouseMsg) -> Cmd {
if let Some(lv) = &self.last_view {
if let Some(on_mouse) = &lv.on_mouse {
Expand Down
22 changes: 18 additions & 4 deletions src/options.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
//! Cleanroom Rust port of upstream Go source file: `options.go`
//! Upstream Target Tag / Version: `v2.0.8`
//!
//! <public-docs>
//! <user-docs>
//! # Program Options
//!
//! Program options (`with_fps`, `without_renderer`, `with_filter`, `with_window_size`,
//! `with_context`, `with_output`, `with_input`, `with_environment`,
//! `without_signal_handler`, `without_catch_panics`, `without_signals`, `with_color_profile`).
//! </public-docs>
//! </user-docs>
//!
//! Maintainer note: options are consumed once by [`crate::program::Program::run`].
//! The input sentinel keeps the default stdin behavior distinct from an explicit
//! `with_input(None)`, which disables input for deterministic headless programs.

use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, Ordering};
Expand Down Expand Up @@ -63,8 +67,11 @@ pub struct ProgramOptions<M: Model> {
pub height: usize,
/// Optional event filter.
pub filter: Option<EventFilter<M>>,
/// Input reader override; None means stdin.
/// Input reader override. The default uses stdin; [`Self::with_input`] with
/// `None` disables input entirely.
pub input: Option<Box<dyn Read + Send + Sync>>,
/// Distinguishes the default stdin input from an explicit disabled input.
input_disabled: bool,
/// Output writer override; None means stdout.
pub output: Option<Box<dyn Write + Send + Sync>>,
/// Environment variables used by the program.
Expand All @@ -87,6 +94,7 @@ impl<M: Model> Default for ProgramOptions<M> {
height: 0,
filter: None,
input: None,
input_disabled: false,
output: None,
environ: None,
color_profile: None,
Expand Down Expand Up @@ -114,10 +122,16 @@ impl<M: Model> ProgramOptions<M> {
/// <upstream-comment>WithInput sets the input which, by default, is stdin. In most cases you
/// won't need to use this. To disable input entirely pass None.</upstream-comment>
pub fn with_input(mut self, input: Option<Box<dyn Read + Send + Sync>>) -> Self {
self.input_disabled = input.is_none();
self.input = input;
self
}

/// Returns whether input was explicitly disabled with [`Self::with_input`].
pub(crate) fn input_disabled(&self) -> bool {
self.input_disabled
}

/// <upstream-comment>WithEnvironment sets the environment variables that the program will use.
/// This is useful when the program is running in a remote session (e.g. SSH) and
/// you want to pass the environment variables from the remote session to the
Expand Down Expand Up @@ -171,7 +185,7 @@ impl<M: Model> ProgramOptions<M> {
/// less than 1, the default value of 60 will be used. If over 120, the FPS
/// will be capped at 120.</upstream-comment>
pub fn with_fps(mut self, fps: u32) -> Self {
self.fps = fps;
self.fps = if fps == 0 { 60 } else { fps.min(120) };
self
}

Expand Down
Loading
Loading