Skip to content

feat(ptbr-tools): the ptbr-writer plugin's runtime scripts as one static Rust binary - #132

Merged
h0ffmann merged 1 commit into
mainfrom
feat/ptbr-tools
Oct 21, 2026
Merged

h0ffmann merged 1 commit into
mainfrom
feat/ptbr-tools

Conversation

@h0ffmann

Copy link
Copy Markdown
Owner

What

New lab labs/ptbr-tools. It ports the three runtime scripts of ptbr-writer from Python to Rust: ask_levels.py (the UserPromptSubmit hook), reading_time.py and route.py (with routes.tsv). The crate uses std only and has no dependencies. The plugin can ship the result as a small static binary.

Binary layout

I built both layouts and kept the smaller one.

layout x86_64 musl, static, stripped
three binaries (ptbr-ask-levels, ptbr-reading-time, ptbr-route) 492 288 + 480 000 + 459 488 = 1 431 776 bytes
one multi-call binary ptbr (runs as the name of the file, or as ptbr <command>) 570 112 bytes (241 720 with xz -9)

The lab ships the multi-call binary. packages.static has bin/ptbr and three symlinks to it. Other builds: aarch64 static musl 528 696 bytes (built, not run), glibc 480 176 bytes with a 47.4 MiB closure. The closure of the static build is 557.8 KiB.

Checks

nix flake check --no-update-lock-file in the lab: build and cargo test (14 tests; every self-test case of the three scripts is ported, and --self-test still works), clippy -D warnings, cargo fmt --check, lab-json, size (fails if the static binary grows more than 10% over 570 112 bytes), and the aarch64 static build. nixpkgs-fmt, statix and deadnix are clean with the lab's pinned nixpkgs (same rev as labs/ptbr-pdf). actionlint and reuse lint are clean.

Differential test against the Python originals

Python 3.11 is an external tool for this test. The harness is not committed. It ran the originals (python3 -I) and the static Rust binary on the same inputs and compared stdout, stderr and the exit code byte for byte: 2 297 comparisons.

  • reading_time, 1 208 runs: the .md files of h0ffmann/ptbr-writer (34, fetched with gh api), the self-test texts, 40 hand-made edge texts (fences with ~~~ and with 4 spaces, unclosed fences, tables, rules, images, <url> links, CRLF, lone CR, front matter, Unicode spaces, U+2028), 400 random texts made of Markdown fragments, each with --lang pt-BR|en and --json; also file arguments, -, a missing file, a directory, usage errors.
  • ask_levels, 829 runs: 66 prompts (triggers, non-triggers, level names with and without accents, decomposed accents, ste100 forms, reconfigurar, escapes, emoji), 9 SKILL.md variants (the real ones, CRLF, no section, empty section), 4 defaults-file variants, about 45 project configs in temp dirs (valid, comments, multi-line arrays, tables, bad values, a folder with .., floats, inline tables, broken TOML) with and without a user config in a temp HOME, 15 hook-JSON stdin variants (invalid JSON, BOM, NaN, duplicate keys, surrogate pairs), argument counts, 300 random prompts, and --self-test.
  • route, 260 runs: 35 requests and 200 random ones against the real routes.tsv and SKILL.md, --check with 7 drifted routes.tsv / SKILL.md pairs, a CRLF routes.tsv, a bad row, a missing file, --self-test.

Result: all outputs are identical, except in these cases. They are on purpose or are Python crashes:

  • Usage text names the Rust command (compared by exit code only).
  • A READING_TIME argument that does not end in .py does not get the python3 -I prefix in the instruction text, so the text names a command that Claude can run. With a .py path the text is identical.
  • 8 runs where Python dies with a traceback and Rust goes on: a project ptbr-writer.toml that is not UTF-8 (Rust reports "cannot read") and a list inside a list in levels (TypeError: unhashable type). For the crashes where both exit with code 1 (non-object JSON, a non-string prompt, too few arguments, a missing routes.tsv), the exit codes are equal.
  • The order of the duplicate-level lines of route --check: Python walks a set, so its order changes between runs. The harness sorts these lines.

Known gaps (⚠, written in the code with shortcut: comments and in the lab README): the accent fold is a table of Portuguese letters plus a drop of U+0300..U+036F, not a full NFD; the TOML reader does not read dates, multi-line strings, dotted keys or [[arrays]] (the hook then reports "cannot read", as it does for a broken file); a lone-surrogate JSON escape becomes U+FFFD.

Not verified (⚠)

  • packages.static-aarch64 is built and checked as a file type; it was not run (no emulator on this machine).
  • aarch64-darwin and x86_64-darwin: not built.
  • just --list ran with just 1.58; the justfile uses only plain syntax, but I did not parse it with just 1.21.
  • The ptbr-writer side is not done: the plugin has to ship bin/ptbr with the three symlinks and call them instead of python3 -I. ptbr-route finds routes.tsv from the path it is started with, so it must sit in <skill>/scripts/.

Other files

  • README.md: one row in the labs table and one in "Who uses picasso" (ptbr-writer, pending its PR).
  • .github/workflows/ci.yml: ptbr-tools is in the labs matrix, in both lists (the lab is light, so it also runs on push).
  • AGENTS.md is not changed: its lab table does not list every lab (viz, paper and gleam-website are missing).

What a consumer has to do

Nothing yet. No lab changes its exported shape. After this lands, the ptbr-writer PR pins this lab.

…tic Rust binary

New lab. It ports ask_levels.py (the UserPromptSubmit hook), reading_time.py and
route.py (+ routes.tsv) of h0ffmann/ptbr-writer to Rust, std only, no crates. One
multi-call binary, `ptbr`, runs as ptbr-ask-levels, ptbr-reading-time or ptbr-route
(symlinks, or `ptbr <command>`). The static musl build is 570 112 bytes; three
separate binaries were 1 431 776 bytes in total, so the lab ships the multi-call one.

Same flags, outputs and exit codes as the Python, with a minimal TOML reader, a
minimal JSON reader/writer, a table-based accent fold and hand-written versions of
the regular expressions. The self-tests are ported to cargo test and --self-test
still works. checks: build + tests, clippy -D warnings, fmt, lab-json, a size
ceiling (+10%), and the aarch64 static build.

Tested: nix flake check --no-update-lock-file in labs/ptbr-tools (build+cargo test, clippy, fmt, lab-json, size, static-aarch64 ok); nixpkgs-fmt, statix, deadnix with the lab's pinned nixpkgs ok; actionlint ok; reuse lint ok; 2297 differential comparisons against the Python originals, 8 differ only where Python crashes with a traceback; aarch64 binary built, not run
Co-Authored-By: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant