From 9d4e91fbb569c617feba27df3fac8b94fcd854d3 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 09:10:06 -0300 Subject: [PATCH 01/23] docs: add Rust statusline core implementation plan Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-05-rust-tokenline-core.md | 1646 +++++++++++++++++ 1 file changed, 1646 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-05-rust-tokenline-core.md diff --git a/docs/superpowers/plans/2026-07-05-rust-tokenline-core.md b/docs/superpowers/plans/2026-07-05-rust-tokenline-core.md new file mode 100644 index 0000000..fdb5856 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-rust-tokenline-core.md @@ -0,0 +1,1646 @@ +# Rust tokenline core binary (v1) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the `tokenline` Rust binary that reads the host CLI's stdin JSON and prints the redesigned **PULSE+** statusline, at logic-parity with `tokenline.sh` and correct on Linux/macOS/Windows. + +**Architecture:** A single binary crate under `rust/`. `main()` is a thin, never-panics shell (read stdin, get `now`, resolve cache dir, `catch_unwind`, print, `exit(0)`). All work is a pure function `render(input, now, cache_dir, density) -> String` split across focused modules: `input` (serde structs), `fmt` (palette + number formatting), `epoch` (ISO→epoch), `economics` + `ratelimits` (pure compute), `cache` + `cachetimer` (stateful), `render` (PULSE+ composition). Correctness comes from a **logic-value oracle** (computed values asserted against the current bash output) plus **render golden fixtures** (PULSE+ output regression). + +**Tech Stack:** Rust (edition 2021, stable), `serde` + `serde_json` only; `std` for time (`SystemTime`), file mtime (`fs::metadata`), and cross-platform dirs. No `chrono`, no `jq`, no coreutils. + +## Global Constraints + +- **Never crash the host.** `main()` always exits 0. No `.unwrap()`/`.expect()`/`?` may escape `main`; a `panic::set_hook` + `catch_unwind` turn any panic into empty output + `exit(0)`. `panic = "unwind"` (never `abort`). +- **Never write session input to disk.** Only the tiny per-turn cache persists, under a `0700` dir (`#[cfg(unix)]`) in `$XDG_RUNTIME_DIR` (Unix) / `%LOCALAPPDATA%` (Windows). +- **Dependencies:** `serde`, `serde_json` only. `std` for everything else. +- **Glyphs:** universal Unicode/emoji only (no nerd-font). Truecolor (24-bit) ANSI. +- **Logic parity, not layout parity:** the *computed values* (eq, saving %, ctx %, ttl, rate-limit %) must match `tokenline.sh`; the *rendered layout* is the new PULSE+ design. +- **Crate location:** `rust/` at repo root, single binary crate `tokenline`. The TS installer under `src/` is untouched. +- **Version:** `0.1.0` (earns `1.0.0` at parity + Windows-verified). +- **`render` takes a `density` argument from day one; v1 implements only `Density::Normal` (PULSE+).** `Compact`/`Verbose` fall back to `Normal` for now (no separate renderer built). +- **Multipliers (from `tokenline.sh:398-418`), verbatim:** + - Anthropic: read `0.1`, write `1.25` (→ `2.0` when the cache window is `1h`), new `1`, output `5`. + - Gemini: read `0.25`, write `1.0`, new `1`, output `4`. + - `eq = cread*read + cwrite*write + cinput*new + coutput*output` + - `uncached_eq = (cread + cwrite + cinput)*new + coutput*output` ← the "vs N full" denominator + - `saving% = floor(100*(uncached_eq - eq)/uncached_eq)`, or `0` if `uncached_eq == 0`. +- **Cache TTL (from `tokenline.sh:231-250`):** Gemini → `300`/`"5m"`. Anthropic → `1h`/`3600` if `ephemeral_1h > 0`, else `5m`/`300` if `ephemeral_5m > 0`, else the previously-cached ttl, else `5m`/`300`. + +--- + +## File Structure + +``` +rust/ + Cargo.toml + src/ + main.rs — thin shell; never panics; exit 0 + lib.rs — module wiring + `pub fn render(&Input, now, &Path, Density) -> String` + input.rs — serde structs (RawInput) + derived `Session` + fmt.rs — truecolor palette (Role), Style, paint(); fmt_k, fmt_eta + epoch.rs — iso8601_to_epoch(&str) -> Option + economics.rs — Economics { eq, uncached_eq, saving_pct, tiers } + compute + ratelimits.rs — RlWindow compute (pct, eta, pace) + bar() + cache.rs — runtime_dir(), CacheFile read/write (consolidated), turn detection + cachetimer.rs — ttl determination + transcript last-turn read + CacheInfo + render.rs — Density enum + PULSE+ (Normal) line composition + tests/ + fixtures/ — *.json inputs + *.values.json expected computed values + logic_oracle.rs — assert computed values match bash reference + golden.rs — assert PULSE+ rendered output matches committed .txt (ANSI-stripped) +``` + +Each module below is introduced by the task that creates it. `lib.rs` grows as modules land; `render.rs` and `main.rs` come last. + +--- + +### Task 1: Crate scaffold + input parsing + tracer + +**Files:** +- Create: `rust/Cargo.toml`, `rust/src/main.rs`, `rust/src/lib.rs`, `rust/src/input.rs` +- Test: `rust/src/input.rs` (inline `#[cfg(test)]`) + +**Interfaces:** +- Produces: `RawInput` (serde struct of the stdin JSON), `Session::from_raw(RawInput) -> Session` with derived fields `model, used_pct: Option, tokens_limit: Option, transcript_path: String, session_id: String, rl_5h/7d: Option, cur_input/output/cwrite/cread: u64, tokens_used: u64, cli_client: Client, is_gemini: bool`. + +- [ ] **Step 1: Write the failing test** + +In `rust/src/input.rs`: +```rust +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#"{ + "model": {"display_name": "Opus 4.8"}, + "context_window": { + "used_percentage": 9.0, "context_window_size": 1000000, + "current_usage": {"input_tokens": 2, "output_tokens": 2000, + "cache_creation_input_tokens": 2200, "cache_read_input_tokens": 86600} + }, + "transcript_path": "/home/u/.claude/projects/p/abc.jsonl", + "session_id": "abc", + "rate_limits": { + "five_hour": {"used_percentage": 12.0, "resets_at": "2026-07-05T18:00:00Z"}, + "seven_day": {"used_percentage": 64.0, "resets_at": "2026-07-06T12:00:00Z"} + } + }"#; + + #[test] + fn parses_and_derives() { + let raw: RawInput = serde_json::from_str(SAMPLE).unwrap(); + let s = Session::from_raw(raw); + assert_eq!(s.model, "Opus 4.8"); + assert_eq!(s.tokens_limit, Some(1_000_000)); + assert_eq!(s.cur_cread, 86_600); + assert_eq!(s.tokens_used, 2 + 2200 + 86600); // input + cwrite + cread + assert_eq!(s.session_id, "abc"); + assert!(!s.is_gemini); + assert_eq!(s.cli_client, Client::ClaudeCode); + } + + #[test] + fn missing_fields_default_gracefully() { + let raw: RawInput = serde_json::from_str("{}").unwrap(); + let s = Session::from_raw(raw); + assert_eq!(s.model, ""); + assert_eq!(s.tokens_limit, None); + assert_eq!(s.tokens_used, 0); + } + + #[test] + fn detects_gemini_and_antigravity() { + let raw: RawInput = serde_json::from_str( + r#"{"model":{"display_name":"Gemini 2.5 Pro"}, + "transcript_path":"/x/antigravity/y/z.jsonl"}"#).unwrap(); + let s = Session::from_raw(raw); + assert!(s.is_gemini); + assert_eq!(s.cli_client, Client::Antigravity); + assert!(s.transcript_path.contains("/antigravity-cli/")); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd rust && cargo test input` +Expected: FAIL — `RawInput`/`Session` not defined. + +- [ ] **Step 3: Write `Cargo.toml`** + +```toml +[package] +name = "tokenline" +version = "0.1.0" +edition = "2021" +description = "A cache-aware statusline for AI coding CLIs" +license = "MIT" + +[[bin]] +name = "tokenline" +path = "src/main.rs" + +[lib] +name = "tokenline" +path = "src/lib.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "z" +lto = true +strip = true +panic = "unwind" # catch_unwind MUST work; abort would be a non-zero exit +``` + +- [ ] **Step 4: Write `rust/src/input.rs`** + +```rust +use serde::Deserialize; + +#[derive(Deserialize, Default)] +pub struct RawInput { + #[serde(default)] pub model: Model, + #[serde(default)] pub context_window: ContextWindow, + #[serde(default)] pub transcript_path: String, + #[serde(default)] pub session_id: String, + #[serde(default)] pub rate_limits: RateLimits, +} + +#[derive(Deserialize, Default)] +pub struct Model { #[serde(default)] pub display_name: String } + +#[derive(Deserialize, Default)] +pub struct ContextWindow { + #[serde(default)] pub used_percentage: Option, + #[serde(default)] pub context_window_size: Option, + #[serde(default)] pub current_usage: Usage, +} + +#[derive(Deserialize, Default)] +pub struct Usage { + #[serde(default)] pub input_tokens: u64, + #[serde(default)] pub output_tokens: u64, + #[serde(default)] pub cache_creation_input_tokens: u64, + #[serde(default)] pub cache_read_input_tokens: u64, +} + +#[derive(Deserialize, Default)] +pub struct RateLimits { + #[serde(default)] pub five_hour: Option, + #[serde(default)] pub seven_day: Option, +} + +#[derive(Deserialize, Default, Clone)] +pub struct RlRaw { + #[serde(default)] pub used_percentage: Option, + #[serde(default)] pub resets_at: Option, +} + +#[derive(PartialEq, Debug)] +pub enum Client { ClaudeCode, Antigravity } + +pub struct Session { + pub model: String, + pub used_pct: Option, + pub tokens_limit: Option, + pub transcript_path: String, + pub session_id: String, + pub rl_5h: Option, + pub rl_7d: Option, + pub cur_input: u64, + pub cur_output: u64, + pub cur_cwrite: u64, + pub cur_cread: u64, + pub tokens_used: u64, + pub cli_client: Client, + pub is_gemini: bool, +} + +impl Session { + pub fn from_raw(r: RawInput) -> Session { + let u = r.context_window.current_usage; + let tokens_used = u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens; + + // Subagent transcript -> parent session (tokenline.sh:123-125) + let mut transcript_path = r.transcript_path; + if transcript_path.contains("/subagents/") { + if let Some(parent) = std::path::Path::new(&transcript_path) + .parent().and_then(|p| p.parent()) + { + transcript_path = format!("{}.jsonl", parent.display()); + } + } + + // Client + antigravity path correction (tokenline.sh:127-136) + let cli_client = if transcript_path.contains("/antigravity") { + Client::Antigravity + } else { + Client::ClaudeCode + }; + if cli_client == Client::Antigravity && transcript_path.contains("/antigravity/") { + transcript_path = transcript_path.replace("/antigravity/", "/antigravity-cli/"); + } + + let is_gemini = r.model.display_name.to_lowercase().contains("gemini"); + + Session { + model: r.model.display_name, + used_pct: r.context_window.used_percentage, + tokens_limit: r.context_window.context_window_size, + transcript_path, + session_id: r.session_id, + rl_5h: r.rate_limits.five_hour, + rl_7d: r.rate_limits.seven_day, + cur_input: u.input_tokens, + cur_output: u.output_tokens, + cur_cwrite: u.cache_creation_input_tokens, + cur_cread: u.cache_read_input_tokens, + tokens_used, + cli_client, + is_gemini, + } + } +} +``` + +Create `rust/src/lib.rs`: +```rust +pub mod input; +``` + +Create `rust/src/main.rs` (tracer; hardened in Task 9): +```rust +use std::io::Read; +use tokenline::input::{RawInput, Session}; + +fn main() { + let mut buf = String::new(); + if std::io::stdin().read_to_string(&mut buf).is_err() { return; } + match serde_json::from_str::(&buf) { + Ok(raw) => println!("{}", Session::from_raw(raw).model), + Err(_) => {} // silent no-op on malformed input + } +} +``` + +- [ ] **Step 5: Run test + tracer** + +Run: `cd rust && cargo test input` +Expected: PASS (3 tests). +Run: `echo '{"model":{"display_name":"Opus 4.8"}}' | cargo run -q` +Expected: prints `Opus 4.8`. + +- [ ] **Step 6: Commit** + +```bash +git add rust/Cargo.toml rust/src/input.rs rust/src/lib.rs rust/src/main.rs +git commit -m "feat(rust): scaffold crate, parse stdin JSON into Session" +``` + +--- + +### Task 2: `fmt` — truecolor palette + number formatting + +**Files:** +- Create: `rust/src/fmt.rs` +- Modify: `rust/src/lib.rs` (add `pub mod fmt;`) +- Test: `rust/src/fmt.rs` inline + +**Interfaces:** +- Produces: + - `enum Role { Ink, Muted, Faint, Good, Warn, Caution, Critical, Cache, Accent, Read, New, Write, Output }` + - `struct Style { role: Role, bold: bool, blink: bool }` with `Role::paint(text, bold, blink) -> String` (emits truecolor ANSI, always resets). + - `fn fmt_k(v: u64) -> String` (1_500_000 → "1.5M", 25_600 → "25.6k", 900 → "900"). + - `fn fmt_eta(secs: i64) -> String` (0→"now", 90→"1m", 3600→"1h", 5400→"1h30m", 90000→"1d1h"). + +- [ ] **Step 1: Write the failing test** + +```rust +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn fmt_k_scales() { + assert_eq!(fmt_k(1_500_000), "1.5M"); + assert_eq!(fmt_k(25_600), "25.6k"); + assert_eq!(fmt_k(86_600), "86.6k"); + assert_eq!(fmt_k(900), "900"); + assert_eq!(fmt_k(0), "0"); + } + #[test] + fn fmt_eta_buckets() { + assert_eq!(fmt_eta(0), "now"); + assert_eq!(fmt_eta(-5), "now"); + assert_eq!(fmt_eta(90), "1m"); + assert_eq!(fmt_eta(3600), "1h"); + assert_eq!(fmt_eta(5400), "1h30m"); + assert_eq!(fmt_eta(90_000), "1d1h"); + assert_eq!(fmt_eta(86_400), "1d"); + } + #[test] + fn paint_wraps_and_resets() { + let s = Role::Good.paint("hi", false, false); + assert!(s.starts_with("\x1b[38;2;63;185;80m")); + assert!(s.ends_with("\x1b[0m")); + assert!(s.contains("hi")); + } + #[test] + fn paint_bold_and_blink() { + let s = Role::Critical.paint("x", true, true); + assert!(s.contains("\x1b[1m")); + assert!(s.contains("\x1b[5m")); + } +} +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cd rust && cargo test fmt` +Expected: FAIL — module/functions not defined. + +- [ ] **Step 3: Write `rust/src/fmt.rs`** + +```rust +#[derive(Clone, Copy)] +pub enum Role { + Ink, Muted, Faint, Good, Warn, Caution, Critical, Cache, Accent, + Read, New, Write, Output, +} + +impl Role { + // PULSE+ refined truecolor palette (24-bit). + fn rgb(self) -> (u8, u8, u8) { + match self { + Role::Ink => (0xe6, 0xed, 0xf3), + Role::Muted => (0x8b, 0x94, 0x9e), + Role::Faint => (0x5b, 0x66, 0x73), + Role::Good => (0x3f, 0xb9, 0x50), + Role::Warn => (0xd2, 0x99, 0x22), + Role::Caution => (0xdb, 0x6d, 0x28), + Role::Critical => (0xf0, 0x50, 0x3c), + Role::Cache => (0x3f, 0xb9, 0x50), + Role::Accent => (0x58, 0xa6, 0xff), + Role::Read => (0x45, 0xc4, 0xa8), + Role::New => (0x6c, 0x9e, 0xff), + Role::Write => (0xe0, 0xa4, 0x58), + Role::Output => (0xd1, 0x8a, 0xc9), + } + } + pub fn paint(self, text: &str, bold: bool, blink: bool) -> String { + let (r, g, b) = self.rgb(); + let mut s = String::new(); + s.push_str(&format!("\x1b[38;2;{};{};{}m", r, g, b)); + if bold { s.push_str("\x1b[1m"); } + if blink { s.push_str("\x1b[5m"); } + s.push_str(text); + s.push_str("\x1b[0m"); + s + } +} + +/// 1_500_000 -> "1.5M", 25_600 -> "25.6k", 900 -> "900". +/// Mirrors tokenline.sh fmt_k (awk %.1f). +pub fn fmt_k(v: u64) -> String { + if v >= 1_000_000 { + format!("{:.1}M", v as f64 / 1_000_000.0) + } else if v >= 1_000 { + format!("{:.1}k", v as f64 / 1_000.0) + } else { + v.to_string() + } +} + +/// Mirrors tokenline.sh fmt_eta. +pub fn fmt_eta(secs: i64) -> String { + if secs <= 0 { return "now".to_string(); } + if secs < 3600 { return format!("{}m", secs / 60); } + if secs < 86_400 { + let (h, m) = (secs / 3600, (secs % 3600) / 60); + return if m > 0 { format!("{}h{}m", h, m) } else { format!("{}h", h) }; + } + let (d, h) = (secs / 86_400, (secs % 86_400) / 3600); + if h > 0 { format!("{}d{}h", d, h) } else { format!("{}d", d) } +} +``` + +Add to `rust/src/lib.rs`: `pub mod fmt;` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd rust && cargo test fmt` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add rust/src/fmt.rs rust/src/lib.rs +git commit -m "feat(rust): truecolor palette + fmt_k/fmt_eta" +``` + +--- + +### Task 3: `epoch` — ISO-8601 → epoch (hand-rolled) + +**Files:** +- Create: `rust/src/epoch.rs` +- Modify: `rust/src/lib.rs` (`pub mod epoch;`) +- Test: `rust/src/epoch.rs` inline + +**Interfaces:** +- Produces: `fn iso8601_to_epoch(iso: &str) -> Option` — parses the first 19 chars `YYYY-MM-DDTHH:MM:SS` as UTC (transcripts are always UTC, matching `tokenline.sh:54`). Returns `None` on malformed input. + +- [ ] **Step 1: Write the failing test** + +```rust +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn epoch_unix_zero() { + assert_eq!(iso8601_to_epoch("1970-01-01T00:00:00Z"), Some(0)); + } + #[test] + fn epoch_known_instant() { + // 2026-07-05T18:00:00Z == 1783274400 + assert_eq!(iso8601_to_epoch("2026-07-05T18:00:00Z"), Some(1_783_274_400)); + } + #[test] + fn epoch_ignores_fractional_and_offset_suffix() { + assert_eq!(iso8601_to_epoch("2026-07-05T18:00:00.123456Z"), Some(1_783_274_400)); + } + #[test] + fn epoch_leap_day() { + // 2024-02-29T00:00:00Z == 1709164800 + assert_eq!(iso8601_to_epoch("2024-02-29T00:00:00Z"), Some(1_709_164_800)); + } + #[test] + fn epoch_rejects_garbage() { + assert_eq!(iso8601_to_epoch("not-a-date"), None); + assert_eq!(iso8601_to_epoch(""), None); + } +} +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cd rust && cargo test epoch` +Expected: FAIL — not defined. + +- [ ] **Step 3: Write `rust/src/epoch.rs`** + +```rust +/// Days from civil date to days-since-1970 (Howard Hinnant's algorithm). +/// Correct across leap years; no external date library needed. +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; // [0, 399] + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; // [0, 365] + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096] + era * 146_097 + doe - 719_468 +} + +/// Parse `YYYY-MM-DDTHH:MM:SS` (first 19 chars, UTC) -> epoch seconds. +pub fn iso8601_to_epoch(iso: &str) -> Option { + let b = iso.as_bytes(); + if b.len() < 19 { return None; } + let num = |lo: usize, hi: usize| -> Option { + std::str::from_utf8(&b[lo..hi]).ok()?.parse::().ok() + }; + // Positions: 0123-56-89 T 11:14:17 + if b[4] != b'-' || b[7] != b'-' || (b[10] != b'T' && b[10] != b' ') + || b[13] != b':' || b[16] != b':' { return None; } + let year = num(0, 4)?; + let month = num(5, 7)?; + let day = num(8, 10)?; + let hour = num(11, 13)?; + let min = num(14, 16)?; + let sec = num(17, 19)?; + if !(1..=12).contains(&month) || !(1..=31).contains(&day) + || hour > 23 || min > 59 || sec > 60 { return None; } + let days = days_from_civil(year, month, day); + Some(days * 86_400 + hour * 3600 + min * 60 + sec) +} +``` + +Add to `lib.rs`: `pub mod epoch;` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd rust && cargo test epoch` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add rust/src/epoch.rs rust/src/lib.rs +git commit -m "feat(rust): hand-rolled ISO-8601 UTC to epoch" +``` + +--- + +### Task 4: `economics` — per-turn cost, eq, saving + +**Files:** +- Create: `rust/src/economics.rs` +- Modify: `rust/src/lib.rs` (`pub mod economics;`) +- Test: `rust/src/economics.rs` inline (the logic oracle) + +**Interfaces:** +- Consumes: `Session` fields `cur_cread/cwrite/input/output`, `is_gemini`. +- Produces: + - `struct Tier { role: fmt::Role, tokens: u64, eq_contrib: f64 }` (roles Read, New, Write, Output — thermometer order) + - `struct Economics { eq: u64, uncached_eq: u64, saving_pct: i64, tiers: [Tier; 4], any_activity: bool }` + - `fn economics(s: &Session, cache_is_1h: bool) -> Economics` + +- [ ] **Step 1: Write the failing test (oracle values)** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::input::{RawInput, Session}; + + fn sess(json: &str) -> Session { Session::from_raw(serde_json::from_str(json).unwrap()) } + + #[test] + fn anthropic_1h_matches_bash() { + // read 86.6k, write 2.2k, new 2, output 2.0k, cache=1h + // eq = 86600*0.1 + 2200*2 + 2*1 + 2000*5 = 8660 + 4400 + 2 + 10000 = 23062 + // unc = (86600+2200+2)*1 + 2000*5 = 88802 + 10000 = 98802 + // save = floor(100*(98802-23062)/98802) = 76 + let s = sess(r#"{"model":{"display_name":"Opus 4.8"},"context_window":{"current_usage": + {"input_tokens":2,"output_tokens":2000,"cache_creation_input_tokens":2200, + "cache_read_input_tokens":86600}}}"#); + let e = economics(&s, true); + assert_eq!(e.eq, 23_062); + assert_eq!(e.uncached_eq, 98_802); + assert_eq!(e.saving_pct, 76); + assert!(e.any_activity); + assert_eq!(e.tiers[0].role as u8, fmt::Role::Read as u8); // thermometer order + } + + #[test] + fn anthropic_5m_uses_1_25_write() { + // write mult 1.25 when cache window is 5m + let s = sess(r#"{"context_window":{"current_usage": + {"cache_creation_input_tokens":1000,"output_tokens":0, + "input_tokens":0,"cache_read_input_tokens":0}}}"#); + let e = economics(&s, false); + assert_eq!(e.eq, 1250); // 1000 * 1.25 + } + + #[test] + fn gemini_multipliers() { + // read 0.25, write 1.0, new 1, output 4 + let s = sess(r#"{"model":{"display_name":"Gemini 2.5 Pro"},"context_window":{"current_usage": + {"cache_read_input_tokens":1000,"cache_creation_input_tokens":1000, + "input_tokens":1000,"output_tokens":1000}}}"#); + let e = economics(&s, false); + // eq = 250 + 1000 + 1000 + 4000 = 6250 + assert_eq!(e.eq, 6250); + } + + #[test] + fn zero_activity() { + let s = sess("{}"); + let e = economics(&s, false); + assert!(!e.any_activity); + assert_eq!(e.saving_pct, 0); + } +} +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cd rust && cargo test economics` +Expected: FAIL — not defined. + +- [ ] **Step 3: Write `rust/src/economics.rs`** + +```rust +use crate::fmt::Role; +use crate::input::Session; + +pub struct Tier { pub role: Role, pub tokens: u64, pub eq_contrib: f64 } + +pub struct Economics { + pub eq: u64, + pub uncached_eq: u64, + pub saving_pct: i64, + pub tiers: [Tier; 4], + pub any_activity: bool, +} + +pub fn economics(s: &Session, cache_is_1h: bool) -> Economics { + let (read_m, write_m, new_m, out_m) = if s.is_gemini { + (0.25_f64, 1.0, 1.0, 4.0) + } else { + (0.1_f64, if cache_is_1h { 2.0 } else { 1.25 }, 1.0, 5.0) + }; + + let read_eq = s.cur_cread as f64 * read_m; + let write_eq = s.cur_cwrite as f64 * write_m; + let new_eq = s.cur_input as f64 * new_m; + let out_eq = s.cur_output as f64 * out_m; + + // Match bash awk `printf "%d"` (truncation toward zero). + let eq = (read_eq + write_eq + new_eq + out_eq) as u64; + let uncached_eq = + ((s.cur_cread + s.cur_cwrite + s.cur_input) as f64 * new_m + out_eq) as u64; + + let saving_pct = if uncached_eq > 0 { + (100.0 * (uncached_eq as f64 - eq as f64) / uncached_eq as f64) as i64 + } else { 0 }; + + // Thermometer order: cheap -> dear (read, new, write, output). + let tiers = [ + Tier { role: Role::Read, tokens: s.cur_cread, eq_contrib: read_eq }, + Tier { role: Role::New, tokens: s.cur_input, eq_contrib: new_eq }, + Tier { role: Role::Write, tokens: s.cur_cwrite, eq_contrib: write_eq }, + Tier { role: Role::Output, tokens: s.cur_output, eq_contrib: out_eq }, + ]; + + let any_activity = + s.cur_cread > 0 || s.cur_cwrite > 0 || s.cur_input > 0 || s.cur_output > 0; + + Economics { eq, uncached_eq, saving_pct, tiers, any_activity } +} +``` + +Add to `lib.rs`: `pub mod economics;`. (The test references `fmt::Role as u8`; add `#[derive(Clone, Copy)]` already present — also derive nothing extra; `as u8` works on the fieldless enum. Ensure `Role` has no explicit discriminants so ordering is stable.) + +- [ ] **Step 4: Run to verify pass** + +Run: `cd rust && cargo test economics` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add rust/src/economics.rs rust/src/lib.rs +git commit -m "feat(rust): per-turn economics (eq, saving) at bash logic parity" +``` + +--- + +### Task 5: `ratelimits` — pct, ETA, pace heuristic, bar + +**Files:** +- Create: `rust/src/ratelimits.rs` +- Modify: `rust/src/lib.rs` (`pub mod ratelimits;`) +- Test: `rust/src/ratelimits.rs` inline + +**Interfaces:** +- Consumes: `input::RlRaw`, `now: i64`, `epoch::iso8601_to_epoch`. +- Produces: + - `enum Pace { None, Fast, VeryFast }` + - `struct RlWindow { pct: i64, eta_secs: i64, pace: Pace }` + - `fn rl_window(raw: &RlRaw, window_secs: i64, now: i64) -> Option` (None when pct absent) + - `fn rl_bar(pct: i64, width: usize) -> (usize /*filled*/, usize /*empty*/)` + - `fn rl_role(pct: i64) -> (fmt::Role, bool /*blink*/)` (green<25 / yellow<50 / orange<75 / red<90 / blink-red≥90) + +- [ ] **Step 1: Write the failing test** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::input::RlRaw; + + fn raw(pct: f64, reset: &str) -> RlRaw { + RlRaw { used_percentage: Some(pct), resets_at: Some(reset.into()) } + } + + #[test] + fn pct_and_eta() { + let now = 1_783_000_000; + // reset 3600s in the future + let w = rl_window(&raw(12.0, "x"), 18000, now).unwrap(); + assert_eq!(w.pct, 12); + assert_eq!(w.eta_secs, 0); // "x" is unparseable -> eta 0 + } + + #[test] + fn pace_flags_fast_burn() { + // window 18000s, 50% used, elapsed 3600s (10% of window) + // pace = (50 * 18000) / (3600 * 100) = 2.5 -> VeryFast + let now = 1_000_000; + let reset = crate::epoch_str(now + 14_400); // 4h left => 4h elapsed of 5h + let w = rl_window(&raw(50.0, &reset), 18000, now).unwrap(); + assert!(matches!(w.pace, Pace::VeryFast)); + } + + #[test] + fn bar_geometry() { + assert_eq!(rl_bar(0, 10), (0, 10)); + assert_eq!(rl_bar(50, 10), (5, 5)); + assert_eq!(rl_bar(100, 10), (10, 0)); + assert_eq!(rl_bar(250, 10), (10, 0)); // clamped + } + + #[test] + fn color_thresholds() { + assert!(matches!(rl_role(10).0, fmt::Role::Good)); + assert!(matches!(rl_role(30).0, fmt::Role::Warn)); + assert!(matches!(rl_role(60).0, fmt::Role::Caution)); + assert!(matches!(rl_role(80).0, fmt::Role::Critical)); + assert_eq!(rl_role(95).1, true); // blink + } +} +``` + +Add a tiny test helper to `lib.rs` (test-only): re-export a formatter so the pace test can build a reset string: +```rust +#[cfg(test)] +pub fn epoch_str(_e: i64) -> String { "unused".into() } +``` +> NOTE: the pace test builds `reset` from an epoch but `rl_window` parses ISO. To keep the test hermetic, change the pace test to pass `eta` directly. Rewrite `pace_flags_fast_burn` to call the internal `pace_of(pct_int, eta_secs, window_secs)` helper (see Step 3) instead of `rl_window`. Replace the test body with: +```rust + #[test] + fn pace_flags_fast_burn() { + // 50% used, 14400s to reset in an 18000s window => elapsed 3600s + assert!(matches!(pace_of(50, 14_400, 18_000), Pace::VeryFast)); + assert!(matches!(pace_of(30, 15_000, 18_000), Pace::Fast)); // pace ~1.28 + assert!(matches!(pace_of(10, 1_000, 18_000), Pace::None)); // under 20% -> none + } +``` +and delete the `epoch_str` helper. + +- [ ] **Step 2: Run to verify fail** + +Run: `cd rust && cargo test ratelimits` +Expected: FAIL — not defined. + +- [ ] **Step 3: Write `rust/src/ratelimits.rs`** + +```rust +use crate::fmt::Role; +use crate::input::RlRaw; +use crate::epoch::iso8601_to_epoch; + +#[derive(Debug)] +pub enum Pace { None, Fast, VeryFast } + +pub struct RlWindow { pub pct: i64, pub eta_secs: i64, pub pace: Pace } + +/// tokenline.sh:345-363 pace heuristic, extracted for testability. +pub fn pace_of(pct_int: i64, eta_secs: i64, window_secs: i64) -> Pace { + if pct_int < 20 || eta_secs <= 0 || window_secs <= 0 { return Pace::None; } + let mut elapsed = window_secs - eta_secs; + if elapsed < 0 { elapsed = 0; } + if elapsed < window_secs / 10 { return Pace::None; } // need >=10% elapsed + let pace = (pct_int as f64 * window_secs as f64) / (elapsed as f64 * 100.0); + if pace >= 1.5 { Pace::VeryFast } else if pace >= 1.25 { Pace::Fast } else { Pace::None } +} + +pub fn rl_window(raw: &RlRaw, window_secs: i64, now: i64) -> Option { + let pct = raw.used_percentage?; // None => no segment + let pct_int = pct.round() as i64; + let eta_secs = raw.resets_at.as_deref() + .and_then(iso8601_to_epoch) + .map(|r| (r - now).max(0)) + .unwrap_or(0); + Some(RlWindow { pct: pct_int, eta_secs, pace: pace_of(pct_int, eta_secs, window_secs) }) +} + +pub fn rl_bar(pct: i64, width: usize) -> (usize, usize) { + let mut filled = (pct.max(0) as usize * width) / 100; + if filled > width { filled = width; } + (filled, width - filled) +} + +pub fn rl_role(pct: i64) -> (Role, bool) { + if pct >= 90 { (Role::Critical, true) } + else if pct >= 75 { (Role::Critical, false) } + else if pct >= 50 { (Role::Caution, false) } + else if pct >= 25 { (Role::Warn, false) } + else { (Role::Good, false) } +} +``` + +Add to `lib.rs`: `pub mod ratelimits;` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd rust && cargo test ratelimits` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add rust/src/ratelimits.rs rust/src/lib.rs +git commit -m "feat(rust): rate-limit pct/eta/pace/bar at bash logic parity" +``` + +--- + +### Task 6: `cache` — cross-platform runtime dir + consolidated per-session file + +**Files:** +- Create: `rust/src/cache.rs` +- Modify: `rust/src/lib.rs` (`pub mod cache;`) +- Test: `rust/src/cache.rs` inline (uses `std::env::temp_dir` + a unique subdir; no external tempdir crate) + +**Interfaces:** +- Produces: + - `fn runtime_dir() -> std::path::PathBuf` — Unix `$XDG_RUNTIME_DIR/tokenline-$UID` (fallback `/tmp/tokenline-$UID`), Windows `%LOCALAPPDATA%\tokenline` (fallback `env::temp_dir()/tokenline`). Creates it; `0700` on Unix. + - `struct CacheState { last_ts: Option, ttl: i64, ttl_label: String, last_tokens: u64 }` + - `fn load(dir: &Path, session_id: &str) -> CacheState` (missing/corrupt file → defaults, never errors) + - `fn store(dir: &Path, session_id: &str, st: &CacheState)` (best-effort; ignores I/O errors) + +Consolidated file format (one line, space-separated, replaces the bash 3-file scheme): +` ` e.g. `1783274400 3600 1h 88802`. + +- [ ] **Step 1: Write the failing test** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn scratch() -> std::path::PathBuf { + let mut d = std::env::temp_dir(); + // unique-ish without rand/time crates: process id + d.push(format!("tokenline-test-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn roundtrip() { + let dir = scratch(); + let st = CacheState { last_ts: Some(1_783_274_400), ttl: 3600, + ttl_label: "1h".into(), last_tokens: 88_802 }; + store(&dir, "sess1", &st); + let got = load(&dir, "sess1"); + assert_eq!(got.last_ts, Some(1_783_274_400)); + assert_eq!(got.ttl, 3600); + assert_eq!(got.ttl_label, "1h"); + assert_eq!(got.last_tokens, 88_802); + } + + #[test] + fn missing_file_defaults() { + let dir = scratch(); + let got = load(&dir, "does-not-exist"); + assert_eq!(got.last_ts, None); + assert_eq!(got.ttl_label, "5m"); // default window + } + + #[test] + fn runtime_dir_exists() { + let d = runtime_dir(); + assert!(d.exists()); + } +} +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cd rust && cargo test cache` +Expected: FAIL — not defined. + +- [ ] **Step 3: Write `rust/src/cache.rs`** + +```rust +use std::path::{Path, PathBuf}; + +pub struct CacheState { + pub last_ts: Option, + pub ttl: i64, + pub ttl_label: String, + pub last_tokens: u64, +} + +impl Default for CacheState { + fn default() -> Self { + CacheState { last_ts: None, ttl: 300, ttl_label: "5m".into(), last_tokens: 0 } + } +} + +pub fn runtime_dir() -> PathBuf { + let base = platform_base(); + let _ = std::fs::create_dir_all(&base); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700)); + } + if base.is_dir() { base } else { std::env::temp_dir() } +} + +#[cfg(unix)] +fn platform_base() -> PathBuf { + let uid = std::env::var("UID").unwrap_or_default(); // often unset; fall through below + let uid = if uid.is_empty() { unix_uid() } else { uid }; + let root = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into()); + PathBuf::from(root).join(format!("tokenline-{}", uid)) +} + +#[cfg(unix)] +fn unix_uid() -> String { + // SAFETY: getuid() is always safe and never fails. + (unsafe { libc_getuid() }).to_string() +} +#[cfg(unix)] +extern "C" { #[link_name = "getuid"] fn libc_getuid() -> u32; } + +#[cfg(windows)] +fn platform_base() -> PathBuf { + let root = std::env::var("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()); + root.join("tokenline") +} + +fn file_for(dir: &Path, session_id: &str) -> PathBuf { + let id = if session_id.is_empty() { "default" } else { session_id }; + dir.join(format!("session-{}", id)) +} + +pub fn load(dir: &Path, session_id: &str) -> CacheState { + let mut st = CacheState::default(); + let Ok(raw) = std::fs::read_to_string(file_for(dir, session_id)) else { return st; }; + let parts: Vec<&str> = raw.split_whitespace().collect(); + if parts.len() >= 4 { + st.last_ts = parts[0].parse::().ok(); + if let Ok(t) = parts[1].parse::() { st.ttl = t; } + st.ttl_label = parts[2].to_string(); + if let Ok(tk) = parts[3].parse::() { st.last_tokens = tk; } + } + st +} + +pub fn store(dir: &Path, session_id: &str, st: &CacheState) { + let line = format!("{} {} {} {}", + st.last_ts.unwrap_or(0), st.ttl, st.ttl_label, st.last_tokens); + let _ = std::fs::write(file_for(dir, session_id), line); // best-effort +} +``` + +> **Dependency note:** the `extern "C" { getuid }` avoids a `libc` crate dependency and links against the system libc that `std` already links. Confirm the compat matrix build still links; if any target objects, replace `unix_uid()` with reading `/proc/self/loginuid` is NOT portable — instead fall back to `std::env::var("USER")` hashed, but prefer the `getuid` extern which is the smallest correct option. + +Add to `lib.rs`: `pub mod cache;` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd rust && cargo test cache` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add rust/src/cache.rs rust/src/lib.rs +git commit -m "feat(rust): cross-platform runtime dir + consolidated session cache" +``` + +--- + +### Task 7: `cachetimer` — TTL window + transcript last-turn + remaining state + +**Files:** +- Create: `rust/src/cachetimer.rs` +- Modify: `rust/src/lib.rs` (`pub mod cachetimer;`) +- Test: `rust/src/cachetimer.rs` inline (temp dir + temp transcript file + injected `now`) + +**Interfaces:** +- Consumes: `Session`, `now`, cache dir, `cache::{load,store}`, `epoch`. +- Produces: + - `enum Warmth { Warm, Cooling, Cold }` + - `struct CacheInfo { ttl_label: String, remaining_secs: i64, warmth: Warmth, is_1h: bool }` + - `fn cache_info(s: &Session, now: i64, dir: &Path) -> CacheInfo` — the full stateful pipeline (turn detection + transcript read + fallbacks + TTL determination + persist). + +- [ ] **Step 1: Write the failing test** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::input::{RawInput, Session}; + + fn scratch() -> std::path::PathBuf { + let mut d = std::env::temp_dir(); + d.push(format!("tl-ct-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d + } + fn sess(json: &str) -> Session { Session::from_raw(serde_json::from_str(json).unwrap()) } + + #[test] + fn fresh_session_starts_warm_at_full_ttl() { + let dir = scratch(); + // no transcript, no cache => last_ts defaults to now => remaining == ttl + let s = sess(r#"{"session_id":"warm1","context_window":{"current_usage": + {"cache_read_input_tokens":100}}}"#); + let now = 1_783_000_000; + let ci = cache_info(&s, now, &dir); + assert_eq!(ci.ttl_label, "5m"); // no ephemeral fields => default 5m + assert!(ci.remaining_secs > 290 && ci.remaining_secs <= 300); + assert!(matches!(ci.warmth, Warmth::Warm)); + } + + #[test] + fn transcript_1h_window_and_countdown() { + let dir = scratch(); + // transcript whose last assistant turn is 100s ago and has ephemeral_1h > 0 + let tp = dir.join("t1.jsonl"); + std::fs::write(&tp, format!( + "{}\n", + r#"{"type":"assistant","timestamp":"2026-07-05T00:00:00Z", + "message":{"usage":{"cache_creation":{"ephemeral_1h_input_tokens":10, + "ephemeral_5m_input_tokens":0}}}}"# + )).unwrap(); + let turn_epoch = crate::epoch::iso8601_to_epoch("2026-07-05T00:00:00Z").unwrap(); + let now = turn_epoch + 100; // 100s after the turn + let s = sess(&format!(r#"{{"session_id":"h1","transcript_path":"{}", + "context_window":{{"current_usage":{{"cache_read_input_tokens":100}}}}}}"#, + tp.display())); + let ci = cache_info(&s, now, &dir); + assert_eq!(ci.ttl_label, "1h"); + assert!(ci.is_1h); + assert!((ci.remaining_secs - 3500).abs() <= 2); // 3600 - 100 + assert!(matches!(ci.warmth, Warmth::Warm)); + } + + #[test] + fn expired_is_cold() { + let dir = scratch(); + let s = sess(r#"{"session_id":"cold1","context_window":{"current_usage": + {"cache_read_input_tokens":100}}}"#); + // Prime the cache with an old timestamp + 5m ttl + crate::cache::store(&dir, "cold1", &crate::cache::CacheState{ + last_ts: Some(1_000_000), ttl: 300, ttl_label: "5m".into(), last_tokens: 100 }); + let now = 1_000_000 + 400; // 400s later, ttl 300 => expired + let ci = cache_info(&s, now, &dir); + assert!(ci.remaining_secs <= 0); + assert!(matches!(ci.warmth, Warmth::Cold)); + } +} +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cd rust && cargo test cachetimer` +Expected: FAIL — not defined. + +- [ ] **Step 3: Write `rust/src/cachetimer.rs`** + +```rust +use std::path::Path; +use crate::cache::{self, CacheState}; +use crate::epoch::iso8601_to_epoch; +use crate::input::Session; + +#[derive(Debug)] +pub enum Warmth { Warm, Cooling, Cold } + +pub struct CacheInfo { + pub ttl_label: String, + pub remaining_secs: i64, + pub warmth: Warmth, + pub is_1h: bool, +} + +/// Read the last assistant/PLANNER turn's (timestamp, ephemeral_5m, ephemeral_1h) +/// from the tail of the transcript. Returns (last_ts, e5m, e1h). +fn scan_transcript(path: &str) -> (Option, u64, u64) { + let Ok(content) = std::fs::read_to_string(path) else { return (None, 0, 0); }; + // Tail ~200 lines (tokenline.sh:199). Scan from the end for the last match. + for line in content.lines().rev().take(200) { + let Ok(v) = serde_json::from_str::(line) else { continue; }; + let t = v.get("type").and_then(|t| t.as_str()).unwrap_or(""); + if t != "assistant" && t != "PLANNER_RESPONSE" { continue; } + let iso = v.get("timestamp").and_then(|x| x.as_str()) + .or_else(|| v.get("created_at").and_then(|x| x.as_str())); + let cc = v.pointer("/message/usage/cache_creation"); + let e5m = cc.and_then(|c| c.get("ephemeral_5m_input_tokens")).and_then(|x| x.as_u64()).unwrap_or(0); + let e1h = cc.and_then(|c| c.get("ephemeral_1h_input_tokens")).and_then(|x| x.as_u64()).unwrap_or(0); + return (iso.and_then(iso8601_to_epoch), e5m, e1h); + } + (None, 0, 0) +} + +fn file_mtime(path: &str) -> Option { + let meta = std::fs::metadata(path).ok()?; + let mt = meta.modified().ok()?; + let secs = mt.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); + Some(secs as i64) +} + +pub fn cache_info(s: &Session, now: i64, dir: &Path) -> CacheInfo { + let mut state = cache::load(dir, &s.session_id); + + // Turn detection: token count changed => stamp `now` (tokenline.sh:182-188) + if s.tokens_used != state.last_tokens { + state.last_ts = Some(now); + state.last_tokens = s.tokens_used; + } + + // Transcript last-turn timestamp + ephemeral flags (tokenline.sh:195-214) + let (mut last_ts, e5m, e1h) = if !s.transcript_path.is_empty() { + let (ts, a, b) = scan_transcript(&s.transcript_path); + let ts = ts.or_else(|| file_mtime(&s.transcript_path)); // mtime fallback + (ts, a, b) + } else { (None, 0, 0) }; + + // Prefer cached ts if newer or transcript ts missing (tokenline.sh:216-223) + if let Some(cached) = state.last_ts { + if last_ts.map_or(true, |t| cached > t) { last_ts = Some(cached); } + } + // Always have a timestamp (tokenline.sh:225-229) + let last_ts = last_ts.unwrap_or(now); + state.last_ts = Some(last_ts); + + // TTL window determination (tokenline.sh:231-250) + let (ttl, ttl_label) = if s.is_gemini { + (300, "5m") + } else if e1h > 0 { + (3600, "1h") + } else if e5m > 0 { + (300, "5m") + } else { + // fall back to previously-determined session ttl + (state.ttl, if state.ttl == 3600 { "1h" } else { "5m" }) + }; + state.ttl = ttl; + state.ttl_label = ttl_label.to_string(); + cache::store(dir, &s.session_id, &state); + + let elapsed = now - last_ts; + let remaining = ttl - elapsed; + // pct10 == remaining*10/ttl (tokenline.sh:260) + let pct10 = if ttl > 0 { remaining * 10 / ttl } else { 0 }; + let warmth = if remaining <= 0 { Warmth::Cold } + else if pct10 < 2 { Warmth::Cooling } // < 20% left + else { Warmth::Warm }; + + CacheInfo { ttl_label: ttl_label.to_string(), remaining_secs: remaining, warmth, is_1h: ttl == 3600 } +} +``` + +Add to `lib.rs`: `pub mod cachetimer;` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd rust && cargo test cachetimer` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add rust/src/cachetimer.rs rust/src/lib.rs +git commit -m "feat(rust): cache TTL/countdown timer with transcript read" +``` + +--- + +### Task 8: `render` — PULSE+ (Normal density) + golden fixtures + +**Files:** +- Create: `rust/src/render.rs`, `rust/tests/golden.rs`, `rust/tests/fixtures/*.json`, `rust/tests/fixtures/*.txt` +- Modify: `rust/src/lib.rs` (add `pub mod render;` + `pub fn render(...)`) +- Test: `rust/tests/golden.rs` + +**Interfaces:** +- Consumes: everything above. +- Produces: + - `enum Density { Compact, Normal, Verbose }` + - `pub fn render(input: &input::RawInput, now: i64, cache_dir: &Path, density: Density) -> String` — v1 always renders `Normal` (PULSE+). Reads all state; returns the full multi-line string (with trailing `\n` per line, no trailing blank). + - Helper `fn strip_ansi(s: &str) -> String` in the test for stable golden comparison. + +- [ ] **Step 1: Write the failing golden test** + +`rust/tests/golden.rs`: +```rust +use std::path::PathBuf; +use tokenline::{render, Density}; +use tokenline::input::RawInput; + +fn strip_ansi(s: &str) -> String { + let mut out = String::new(); + let b = s.as_bytes(); + let mut i = 0; + while i < b.len() { + if b[i] == 0x1b { + while i < b.len() && b[i] != b'm' { i += 1; } + i += 1; // skip 'm' + } else { out.push(b[i] as char); i += 1; } + } + out +} + +fn fixtures_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") } + +fn cache_scratch() -> PathBuf { + let d = std::env::temp_dir().join(format!("tl-golden-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn healthy_claude_matches_golden() { + let raw: RawInput = serde_json::from_str( + &std::fs::read_to_string(fixtures_dir().join("healthy_claude.json")).unwrap()).unwrap(); + // FIXED now so the cache countdown is deterministic. + let now = 1_783_000_000; + let out = render(&raw, now, &cache_scratch(), Density::Normal); + let got = strip_ansi(&out); + let want = std::fs::read_to_string(fixtures_dir().join("healthy_claude.txt")).unwrap(); + assert_eq!(got, want, "\n--- got ---\n{}\n--- want ---\n{}", got, want); +} + +#[test] +fn gemini_hides_rate_limits() { + let raw: RawInput = serde_json::from_str( + r#"{"model":{"display_name":"Gemini 2.5 Pro"}, + "context_window":{"used_percentage":9.0,"context_window_size":1000000, + "current_usage":{"cache_read_input_tokens":100,"output_tokens":50}}}"#).unwrap(); + let out = strip_ansi(&render(&raw, 1_783_000_000, &cache_scratch(), Density::Normal)); + assert!(!out.contains("5h"), "Gemini has no rate limits"); + assert!(!out.contains("7d")); +} + +#[test] +fn no_activity_hides_cost_line() { + let raw: RawInput = serde_json::from_str( + r#"{"model":{"display_name":"Opus 4.8"}, + "context_window":{"used_percentage":1.0}}"#).unwrap(); + let out = strip_ansi(&render(&raw, 1_783_000_000, &cache_scratch(), Density::Normal)); + assert!(!out.contains("cost "), "no turn => no cost line"); +} +``` + +- [ ] **Step 2: Create the input fixture (no expected output yet)** + +`rust/tests/fixtures/healthy_claude.json`: +```json +{ + "model": {"display_name": "Opus 4.8"}, + "context_window": { + "used_percentage": 9.0, "context_window_size": 1000000, + "current_usage": {"input_tokens": 2, "output_tokens": 2000, + "cache_creation_input_tokens": 2200, "cache_read_input_tokens": 86600} + }, + "transcript_path": "", "session_id": "golden-healthy", + "rate_limits": { + "five_hour": {"used_percentage": 12.0, "resets_at": "2026-07-05T18:00:00Z"}, + "seven_day": {"used_percentage": 64.0, "resets_at": "2026-07-06T12:00:00Z"} + } +} +``` +Create an empty `rust/tests/fixtures/healthy_claude.txt` for now. + +- [ ] **Step 3: Run to verify fail** + +Run: `cd rust && cargo test --test golden` +Expected: FAIL — `render`/`Density` not defined (and the golden mismatch). + +- [ ] **Step 4: Write `rust/src/render.rs`** + +```rust +use std::path::Path; +use crate::input::{RawInput, Session, Client}; +use crate::fmt::{Role, fmt_k, fmt_eta}; +use crate::economics::economics; +use crate::ratelimits::{rl_window, rl_bar, rl_role, Pace, RlWindow}; +use crate::cachetimer::{cache_info, Warmth}; + +#[derive(Clone, Copy)] +pub enum Density { Compact, Normal, Verbose } + +struct Buf(String); +impl Buf { + fn new() -> Self { Buf(String::new()) } + fn seg(&mut self, role: Role, text: &str, bold: bool, blink: bool) { + self.0.push_str(&role.paint(text, bold, blink)); + } + fn raw(&mut self, s: &str) { self.0.push_str(s); } + fn take(self) -> String { self.0 } +} + +/// Public entry. v1: always renders Normal (PULSE+); other densities fall back. +pub fn render(input: &RawInput, now: i64, cache_dir: &Path, _density: Density) -> String { + // RawInput is not Clone; rebuild Session from a fresh parse is avoided by + // moving fields — but render borrows, so clone the small owned bits we need. + let s = Session::from_raw(clone_raw(input)); + render_normal(&s, now, cache_dir) +} + +fn render_normal(s: &Session, now: i64, cache_dir: &Path) -> String { + let ci = cache_info(s, now, cache_dir); + let eco = economics(s, ci.is_1h); + let mut out = String::new(); + + // ---- Line 1: model · ctx · cache ------------------------------------- + let mut l1 = Buf::new(); + // model (with Antigravity brand) + if s.cli_client == Client::Antigravity { + l1.seg(Role::Accent, "🌌 Antigravity", false, false); + l1.seg(Role::Muted, &format!(" ({})", s.model), false, false); + } else { + l1.seg(Role::Muted, &s.model, false, false); + } + // ctx (sparkline + %) + if let Some(pct) = s.used_pct { + let pct = pct.round() as i64; + let (role, bold) = ctx_role(pct); + l1.seg(Role::Faint, " · ", false, false); + l1.seg(Role::Muted, "ctx ", false, false); + l1.seg(role, &spark(pct), bold, false); + l1.seg(role, &format!(" {}%", pct), bold, false); + } + // cache (heartbeat + countdown + drain bar + word) + l1.seg(Role::Faint, " · ", false, false); + let (cache_role, blink, word) = warmth_style(&ci.warmth); + l1.seg(cache_role, "♥ ", false, blink); + l1.seg(Role::Muted, "cache ", false, false); + l1.seg(cache_role, &clock(ci.remaining_secs), true, blink); + l1.seg(cache_role, &format!(" {} ", drain_bar(ci.remaining_secs, ttl_of(&ci))), false, false); + l1.seg(cache_role, word, blink, blink); + out.push_str(&l1.take()); out.push('\n'); + + // ---- Line 2: cost thermometer (only when a turn happened) ------------ + if eco.any_activity { + let mut l2 = Buf::new(); + l2.seg(Role::Muted, "cost ", false, false); + for (glyph, role) in thermometer(&eco) { + l2.seg(role, &glyph, false, false); + } + l2.seg(Role::Accent, &format!(" {}", fmt_k(eco.eq)), true, false); + l2.seg(Role::Faint, &format!(" / {} full", fmt_k(eco.uncached_eq)), false, false); + l2.seg(Role::Faint, " · ", false, false); + let save_role = save_role(eco.saving_pct); + l2.seg(save_role, &format!("↓{}% saved", eco.saving_pct), true, false); + out.push_str(&l2.take()); out.push('\n'); + } + + // ---- Line 3: rate limits (Anthropic only) ---------------------------- + if !s.is_gemini { + let w5 = s.rl_5h.as_ref().and_then(|r| rl_window(r, 18_000, now)); + let w7 = s.rl_7d.as_ref().and_then(|r| rl_window(r, 604_800, now)); + if w5.is_some() || w7.is_some() { + let mut l3 = Buf::new(); + if let Some(w) = &w5 { rl_segment(&mut l3, "5h", w); } + if let Some(w) = &w7 { + if w5.is_some() { l3.seg(Role::Faint, " ", false, false); } + rl_segment(&mut l3, "7d", w); + } + out.push_str(&l3.take()); out.push('\n'); + } + } + + out +} + +fn rl_segment(b: &mut Buf, label: &str, w: &RlWindow) { + let (role, blink) = rl_role(w.pct); + let marker = if matches!(w.pace, Pace::VeryFast) || w.pct >= 75 { "▲ " } else { "▸ " }; + b.seg(role, marker, false, blink); + b.seg(role, &format!("{} ", label), false, false); + let (filled, empty) = rl_bar(w.pct, 10); + b.seg(role, &"█".repeat(filled), false, false); + b.seg(Role::Faint, &"░".repeat(empty), false, false); + b.seg(role, &format!(" {}% ", w.pct), false, blink); + match w.pace { + Pace::VeryFast => b.seg(role, &format!("⚡ pace · empties {} ", fmt_eta(w.eta_secs)), false, false), + Pace::Fast => b.seg(role, "⚡ pace ", false, false), + Pace::None => {} + } + if w.eta_secs > 0 { + b.seg(Role::Faint, &format!("· {} left", fmt_eta(w.eta_secs)), false, false); + } +} + +// ---- small helpers ------------------------------------------------------ +fn ctx_role(pct: i64) -> (Role, bool) { + if pct >= 80 { (Role::Critical, true) } + else if pct >= 50 { (Role::Warn, false) } + else { (Role::Good, false) } +} +fn spark(pct: i64) -> String { + // 2-cell rising meter: ▁▂▃▄▅▆▇█ picked by 8ths. + let blocks = ['▁','▂','▃','▄','▅','▆','▇','█']; + let idx = ((pct.clamp(0, 100) * 8) / 100).clamp(0, 7) as usize; + format!("{}{}", blocks[idx], blocks[idx.min(7)]) +} +fn warmth_style(w: &Warmth) -> (Role, bool, &'static str) { + match w { + Warmth::Warm => (Role::Cache, false, "warm"), + Warmth::Cooling => (Role::Warn, false, "cooling"), + Warmth::Cold => (Role::Critical, true, "COLD"), + } +} +fn clock(remaining: i64) -> String { + let r = remaining.max(0); + format!("{}:{:02}", r / 60, r % 60) +} +fn ttl_of(ci: &crate::cachetimer::CacheInfo) -> i64 { if ci.is_1h { 3600 } else { 300 } } +fn drain_bar(remaining: i64, ttl: i64) -> String { + let filled = if ttl > 0 { (remaining.max(0) * 10 / ttl).clamp(0, 10) as usize } else { 0 }; + format!("{}{}", "█".repeat(filled), "░".repeat(10 - filled)) +} +fn save_role(pct: i64) -> Role { + if pct >= 70 { Role::Good } else if pct >= 50 { Role::Warn } else { Role::Caution } +} +/// Stacked cost thermometer: widths ∝ eq-contribution over ~11 cells, order read·new·write·output. +fn thermometer(eco: &crate::economics::Economics) -> Vec<(String, Role)> { + let total: f64 = eco.tiers.iter().map(|t| t.eq_contrib).sum::().max(1.0); + let cells = 11.0; + eco.tiers.iter().map(|t| { + let n = (t.eq_contrib / total * cells).round() as usize; + let glyph = if n == 0 && t.eq_contrib > 0.0 { "·".to_string() } else { "▉".repeat(n) }; + (glyph, t.role) + }).collect() +} + +fn clone_raw(r: &RawInput) -> RawInput { + // RawInput derives Deserialize+Default; add Clone via serde round-trip to avoid + // threading ownership. Cheap: the payload is tiny. + serde_json::from_str(&serde_json::to_string(r).unwrap_or_default()).unwrap_or_default() +} +``` + +> Add `#[derive(Serialize)]` to the `RawInput` structs in `input.rs` (and `use serde::Serialize;`) so `clone_raw` round-trips — OR simpler: change `render`'s signature to take `Session` and move the `Session::from_raw` call into `main`/`lib`. **Chosen: change the signature.** In `render.rs` make `pub fn render(input: RawInput, ...)` consume by value and call `Session::from_raw(input)` directly; delete `clone_raw`. Update `lib.rs` re-export and `golden.rs`/`main.rs` call sites to pass ownership. (This keeps `input.rs` free of `Serialize`.) + +Update `rust/src/lib.rs`: +```rust +pub mod input; +pub mod fmt; +pub mod epoch; +pub mod economics; +pub mod ratelimits; +pub mod cache; +pub mod cachetimer; +pub mod render; + +pub use render::{render, Density}; +``` +Adjust `render` to consume `RawInput` by value: +```rust +pub fn render(input: RawInput, now: i64, cache_dir: &Path, _density: Density) -> String { + let s = Session::from_raw(input); + render_normal(&s, now, cache_dir) +} +``` +Update `golden.rs` calls to `render(raw, ...)` (pass by value). + +- [ ] **Step 5: Generate + eyeball the golden output, then freeze it** + +Run: `cd rust && cargo test --test golden 2>&1 | sed -n '/--- got ---/,/--- want ---/p'` +Copy the `got` block, **eyeball it against the PULSE+ mockup** (`scratchpad/tokenline-redesign.html`), and if correct write it verbatim to `rust/tests/fixtures/healthy_claude.txt`. +Expected first line (ANSI-stripped), for reference: +``` +Opus 4.8 · ctx ▁▁ 9% · ♥ cache 0:00 ██████████ warm +``` +> NOTE: with `transcript_path:""` and a fresh cache, `remaining == ttl == 300` → clock shows `5:00` and a full drain bar. Confirm the frozen `.txt` reflects the deterministic `now`/empty-cache path, not the mockup's `59:16` (which assumed a 1h transcript). The mockup is the *visual language* reference; the fixture is the *exact deterministic bytes*. + +- [ ] **Step 6: Run to verify pass** + +Run: `cd rust && cargo test --test golden` +Expected: PASS (3 tests). + +- [ ] **Step 7: Commit** + +```bash +git add rust/src/render.rs rust/src/lib.rs rust/tests/golden.rs rust/tests/fixtures/ +git commit -m "feat(rust): PULSE+ render (Normal density) + golden fixtures" +``` + +--- + +### Task 9: `main` never-crash hardening + end-to-end + +**Files:** +- Modify: `rust/src/main.rs` +- Test: `rust/tests/never_crash.rs` + +**Interfaces:** +- Consumes: `tokenline::{render, Density}`, `cache::runtime_dir`. +- Produces: a `main()` that reads stdin, computes `now`, resolves the cache dir, renders under `catch_unwind`, prints, and **always `exit(0)`** with empty output on any failure. + +- [ ] **Step 1: Write the failing test** + +`rust/tests/never_crash.rs`: +```rust +use std::io::Write; +use std::process::{Command, Stdio}; + +fn run(stdin: &str) -> (i32, String) { + let mut child = Command::new(env!("CARGO_BIN_EXE_tokenline")) + .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()) + .spawn().unwrap(); + child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap(); + let out = child.wait_with_output().unwrap(); + (out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).into_owned()) +} + +#[test] +fn malformed_json_exits_zero_silently() { + let (code, out) = run("this is not json"); + assert_eq!(code, 0); + assert!(out.trim().is_empty()); +} + +#[test] +fn empty_stdin_exits_zero() { + let (code, out) = run(""); + assert_eq!(code, 0); + assert!(out.trim().is_empty()); +} + +#[test] +fn valid_input_renders_and_exits_zero() { + let (code, out) = run(r#"{"model":{"display_name":"Opus 4.8"}, + "context_window":{"used_percentage":9.0}}"#); + assert_eq!(code, 0); + assert!(out.contains("Opus 4.8")); +} +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cd rust && cargo test --test never_crash` +Expected: FAIL — current `main` prints only the model name / no render; the render assertion and silence-on-garbage need the hardened main. + +- [ ] **Step 3: Write the hardened `rust/src/main.rs`** + +```rust +use std::io::Read; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokenline::{render, Density}; +use tokenline::cache::runtime_dir; +use tokenline::input::RawInput; + +fn main() { + // Any panic anywhere becomes empty output + exit(0). Never disturb the host. + std::panic::set_hook(Box::new(|_| {})); // suppress backtrace to stderr + let result = std::panic::catch_unwind(run); + // run() returns the string to print (empty on any handled failure). + let line = result.unwrap_or_default(); + if !line.is_empty() { + print!("{}", line); + } + std::process::exit(0); +} + +fn run() -> String { + let mut buf = String::new(); + if std::io::stdin().read_to_string(&mut buf).is_err() { return String::new(); } + let raw: RawInput = match serde_json::from_str(&buf) { + Ok(r) => r, + Err(_) => return String::new(), // malformed => silent no-op + }; + let now = SystemTime::now().duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64).unwrap_or(0); + let dir = runtime_dir(); + render(raw, now, &dir, Density::Normal) +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd rust && cargo build && cargo test --test never_crash` +Expected: PASS (3 tests). +Run the full suite: `cd rust && cargo test` +Expected: all green. + +- [ ] **Step 5: End-to-end smoke against the real sample** + +Run: +```bash +cd rust && cargo build --release +cat tests/fixtures/healthy_claude.json | ./target/release/tokenline | cat -v | head +``` +Expected: the PULSE+ statusline with visible `\033[38;2;...` truecolor escapes (via `cat -v`), model `Opus 4.8`, a `cost ` line, and a `5h`/`7d` line. + +- [ ] **Step 6: Commit** + +```bash +git add rust/src/main.rs rust/tests/never_crash.rs +git commit -m "feat(rust): never-crash main (catch_unwind, silent exit 0)" +``` + +--- + +## Self-Review + +**1. Spec coverage.** Locked decisions (see memory `rust-rewrite-plan`, `tokenline-ui-redesign`): +- Rust crate under `rust/`, single binary → Task 1. ✅ +- `serde_json` + std only, hand-rolled ISO → Tasks 1, 3. ✅ +- Logic parity (eq/saving/ctx/ttl/rl) → Tasks 4, 5, 7 (oracle values from bash formulas). ✅ +- Consolidated single cache file, cross-platform dir, `#[cfg(unix)]` chmod → Task 6. ✅ +- Pure-function core `render(input, now, cache_dir, density)` + golden fixtures → Task 8. ✅ +- Never-crash (`Result`/`catch_unwind`/exit 0/silent) → Task 9. ✅ +- PULSE+ default; `density` arg present, only `Normal` implemented → Task 8. ✅ +- Windows-ready code (paths behind `#[cfg]`, no unix assumptions leaking) → Task 6 (verified in CI in Plan 2). ✅ +- **Deferred to Plan 2 (distribution):** cargo-dist, CI matrix, Windows *execution* test, musl targets. **Deferred to Plan 3:** npm binary delivery. Not in this plan by design. + +**2. Placeholder scan.** No "TODO/handle edge cases/similar to Task N". Two design forks are resolved inline (the `render` signature note in Task 8 chooses "consume `RawInput` by value"; the `getuid` extern in Task 6 states the chosen approach + fallback). Resolve them as written. + +**3. Type consistency.** `Session` fields (Task 1) are consumed with the same names in Tasks 4/5/7. `economics()` returns `Economics{eq,uncached_eq,saving_pct,tiers,any_activity}` used verbatim in Task 8. `cache_info()` returns `CacheInfo{ttl_label,remaining_secs,warmth,is_1h}` used in Task 8. `render(raw: RawInput, now, dir, Density)` signature is identical across Task 8, `lib.rs`, `golden.rs`, and `main.rs`. `rl_window`/`rl_bar`/`rl_role`/`pace_of` signatures match between Task 5 and Task 8. ✅ + +**Known follow-ups (not defects):** the golden `.txt` is frozen from the deterministic empty-transcript path (Task 8, Step 5) — when Plan 2 adds real-transcript fixtures, freeze those under a fixed `now` too. The cost-thermometer "learn teal=cheap" onboarding gap is a product decision tracked in `tokenline-ui-redesign`, addressed by `--verbose` (a later density mode), not this plan. From 477e1d3a3d3ff8c6cbd8d561439b6507c5dea182 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 09:12:58 -0300 Subject: [PATCH 02/23] feat(rust): scaffold crate, parse stdin JSON into Session --- rust/.gitignore | 1 + rust/Cargo.lock | 107 +++++++++++++++++++++++++++++++ rust/Cargo.toml | 24 +++++++ rust/src/input.rs | 159 ++++++++++++++++++++++++++++++++++++++++++++++ rust/src/lib.rs | 1 + rust/src/main.rs | 11 ++++ 6 files changed, 303 insertions(+) create mode 100644 rust/.gitignore create mode 100644 rust/Cargo.lock create mode 100644 rust/Cargo.toml create mode 100644 rust/src/input.rs create mode 100644 rust/src/lib.rs create mode 100644 rust/src/main.rs diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..df00b6f --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokenline" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..176c313 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "tokenline" +version = "0.1.0" +edition = "2021" +description = "A cache-aware statusline for AI coding CLIs" +license = "MIT" + +[[bin]] +name = "tokenline" +path = "src/main.rs" + +[lib] +name = "tokenline" +path = "src/lib.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "z" +lto = true +strip = true +panic = "unwind" # catch_unwind MUST work; abort would be a non-zero exit diff --git a/rust/src/input.rs b/rust/src/input.rs new file mode 100644 index 0000000..fa9d487 --- /dev/null +++ b/rust/src/input.rs @@ -0,0 +1,159 @@ +use serde::Deserialize; + +#[derive(Deserialize, Default)] +pub struct RawInput { + #[serde(default)] pub model: Model, + #[serde(default)] pub context_window: ContextWindow, + #[serde(default)] pub transcript_path: String, + #[serde(default)] pub session_id: String, + #[serde(default)] pub rate_limits: RateLimits, +} + +#[derive(Deserialize, Default)] +pub struct Model { #[serde(default)] pub display_name: String } + +#[derive(Deserialize, Default)] +pub struct ContextWindow { + #[serde(default)] pub used_percentage: Option, + #[serde(default)] pub context_window_size: Option, + #[serde(default)] pub current_usage: Usage, +} + +#[derive(Deserialize, Default)] +pub struct Usage { + #[serde(default)] pub input_tokens: u64, + #[serde(default)] pub output_tokens: u64, + #[serde(default)] pub cache_creation_input_tokens: u64, + #[serde(default)] pub cache_read_input_tokens: u64, +} + +#[derive(Deserialize, Default)] +pub struct RateLimits { + #[serde(default)] pub five_hour: Option, + #[serde(default)] pub seven_day: Option, +} + +#[derive(Deserialize, Default, Clone)] +pub struct RlRaw { + #[serde(default)] pub used_percentage: Option, + #[serde(default)] pub resets_at: Option, +} + +#[derive(PartialEq, Debug)] +pub enum Client { ClaudeCode, Antigravity } + +pub struct Session { + pub model: String, + pub used_pct: Option, + pub tokens_limit: Option, + pub transcript_path: String, + pub session_id: String, + pub rl_5h: Option, + pub rl_7d: Option, + pub cur_input: u64, + pub cur_output: u64, + pub cur_cwrite: u64, + pub cur_cread: u64, + pub tokens_used: u64, + pub cli_client: Client, + pub is_gemini: bool, +} + +impl Session { + pub fn from_raw(r: RawInput) -> Session { + let u = r.context_window.current_usage; + let tokens_used = u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens; + + // Subagent transcript -> parent session (tokenline.sh:123-125) + let mut transcript_path = r.transcript_path; + if transcript_path.contains("/subagents/") { + if let Some(parent) = std::path::Path::new(&transcript_path) + .parent().and_then(|p| p.parent()) + { + transcript_path = format!("{}.jsonl", parent.display()); + } + } + + // Client + antigravity path correction (tokenline.sh:127-136) + let cli_client = if transcript_path.contains("/antigravity") { + Client::Antigravity + } else { + Client::ClaudeCode + }; + if cli_client == Client::Antigravity && transcript_path.contains("/antigravity/") { + transcript_path = transcript_path.replace("/antigravity/", "/antigravity-cli/"); + } + + let is_gemini = r.model.display_name.to_lowercase().contains("gemini"); + + Session { + model: r.model.display_name, + used_pct: r.context_window.used_percentage, + tokens_limit: r.context_window.context_window_size, + transcript_path, + session_id: r.session_id, + rl_5h: r.rate_limits.five_hour, + rl_7d: r.rate_limits.seven_day, + cur_input: u.input_tokens, + cur_output: u.output_tokens, + cur_cwrite: u.cache_creation_input_tokens, + cur_cread: u.cache_read_input_tokens, + tokens_used, + cli_client, + is_gemini, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#"{ + "model": {"display_name": "Opus 4.8"}, + "context_window": { + "used_percentage": 9.0, "context_window_size": 1000000, + "current_usage": {"input_tokens": 2, "output_tokens": 2000, + "cache_creation_input_tokens": 2200, "cache_read_input_tokens": 86600} + }, + "transcript_path": "/home/u/.claude/projects/p/abc.jsonl", + "session_id": "abc", + "rate_limits": { + "five_hour": {"used_percentage": 12.0, "resets_at": "2026-07-05T18:00:00Z"}, + "seven_day": {"used_percentage": 64.0, "resets_at": "2026-07-06T12:00:00Z"} + } + }"#; + + #[test] + fn parses_and_derives() { + let raw: RawInput = serde_json::from_str(SAMPLE).unwrap(); + let s = Session::from_raw(raw); + assert_eq!(s.model, "Opus 4.8"); + assert_eq!(s.tokens_limit, Some(1_000_000)); + assert_eq!(s.cur_cread, 86_600); + assert_eq!(s.tokens_used, 2 + 2200 + 86600); // input + cwrite + cread + assert_eq!(s.session_id, "abc"); + assert!(!s.is_gemini); + assert_eq!(s.cli_client, Client::ClaudeCode); + } + + #[test] + fn missing_fields_default_gracefully() { + let raw: RawInput = serde_json::from_str("{}").unwrap(); + let s = Session::from_raw(raw); + assert_eq!(s.model, ""); + assert_eq!(s.tokens_limit, None); + assert_eq!(s.tokens_used, 0); + } + + #[test] + fn detects_gemini_and_antigravity() { + let raw: RawInput = serde_json::from_str( + r#"{"model":{"display_name":"Gemini 2.5 Pro"}, + "transcript_path":"/x/antigravity/y/z.jsonl"}"#).unwrap(); + let s = Session::from_raw(raw); + assert!(s.is_gemini); + assert_eq!(s.cli_client, Client::Antigravity); + assert!(s.transcript_path.contains("/antigravity-cli/")); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..7839bc5 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1 @@ +pub mod input; diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 0000000..6c1f95e --- /dev/null +++ b/rust/src/main.rs @@ -0,0 +1,11 @@ +use std::io::Read; +use tokenline::input::{RawInput, Session}; + +fn main() { + let mut buf = String::new(); + if std::io::stdin().read_to_string(&mut buf).is_err() { return; } + match serde_json::from_str::(&buf) { + Ok(raw) => println!("{}", Session::from_raw(raw).model), + Err(_) => {} // silent no-op on malformed input + } +} From acf050878ae8a21f193d3a52d6073a4a29f91a77 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 09:24:38 -0300 Subject: [PATCH 03/23] feat(rust): truecolor palette + fmt_k/fmt_eta --- rust/src/fmt.rs | 96 +++++++++++++++++++++++++++++++++++++++++++++++++ rust/src/lib.rs | 1 + 2 files changed, 97 insertions(+) create mode 100644 rust/src/fmt.rs diff --git a/rust/src/fmt.rs b/rust/src/fmt.rs new file mode 100644 index 0000000..09a10e5 --- /dev/null +++ b/rust/src/fmt.rs @@ -0,0 +1,96 @@ +#[derive(Clone, Copy)] +pub enum Role { + Ink, Muted, Faint, Good, Warn, Caution, Critical, Cache, Accent, + Read, New, Write, Output, +} + +impl Role { + // PULSE+ refined truecolor palette (24-bit). + fn rgb(self) -> (u8, u8, u8) { + match self { + Role::Ink => (0xe6, 0xed, 0xf3), + Role::Muted => (0x8b, 0x94, 0x9e), + Role::Faint => (0x5b, 0x66, 0x73), + Role::Good => (0x3f, 0xb9, 0x50), + Role::Warn => (0xd2, 0x99, 0x22), + Role::Caution => (0xdb, 0x6d, 0x28), + Role::Critical => (0xf0, 0x50, 0x3c), + Role::Cache => (0x3f, 0xb9, 0x50), + Role::Accent => (0x58, 0xa6, 0xff), + Role::Read => (0x45, 0xc4, 0xa8), + Role::New => (0x6c, 0x9e, 0xff), + Role::Write => (0xe0, 0xa4, 0x58), + Role::Output => (0xd1, 0x8a, 0xc9), + } + } + pub fn paint(self, text: &str, bold: bool, blink: bool) -> String { + let (r, g, b) = self.rgb(); + let mut s = String::new(); + s.push_str(&format!("\x1b[38;2;{};{};{}m", r, g, b)); + if bold { s.push_str("\x1b[1m"); } + if blink { s.push_str("\x1b[5m"); } + s.push_str(text); + s.push_str("\x1b[0m"); + s + } +} + +/// 1_500_000 -> "1.5M", 25_600 -> "25.6k", 900 -> "900". +/// Mirrors tokenline.sh fmt_k (awk %.1f). +pub fn fmt_k(v: u64) -> String { + if v >= 1_000_000 { + format!("{:.1}M", v as f64 / 1_000_000.0) + } else if v >= 1_000 { + format!("{:.1}k", v as f64 / 1_000.0) + } else { + v.to_string() + } +} + +/// Mirrors tokenline.sh fmt_eta. +pub fn fmt_eta(secs: i64) -> String { + if secs <= 0 { return "now".to_string(); } + if secs < 3600 { return format!("{}m", secs / 60); } + if secs < 86_400 { + let (h, m) = (secs / 3600, (secs % 3600) / 60); + return if m > 0 { format!("{}h{}m", h, m) } else { format!("{}h", h) }; + } + let (d, h) = (secs / 86_400, (secs % 86_400) / 3600); + if h > 0 { format!("{}d{}h", d, h) } else { format!("{}d", d) } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn fmt_k_scales() { + assert_eq!(fmt_k(1_500_000), "1.5M"); + assert_eq!(fmt_k(25_600), "25.6k"); + assert_eq!(fmt_k(86_600), "86.6k"); + assert_eq!(fmt_k(900), "900"); + assert_eq!(fmt_k(0), "0"); + } + #[test] + fn fmt_eta_buckets() { + assert_eq!(fmt_eta(0), "now"); + assert_eq!(fmt_eta(-5), "now"); + assert_eq!(fmt_eta(90), "1m"); + assert_eq!(fmt_eta(3600), "1h"); + assert_eq!(fmt_eta(5400), "1h30m"); + assert_eq!(fmt_eta(90_000), "1d1h"); + assert_eq!(fmt_eta(86_400), "1d"); + } + #[test] + fn paint_wraps_and_resets() { + let s = Role::Good.paint("hi", false, false); + assert!(s.starts_with("\x1b[38;2;63;185;80m")); + assert!(s.ends_with("\x1b[0m")); + assert!(s.contains("hi")); + } + #[test] + fn paint_bold_and_blink() { + let s = Role::Critical.paint("x", true, true); + assert!(s.contains("\x1b[1m")); + assert!(s.contains("\x1b[5m")); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 7839bc5..2b497da 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1 +1,2 @@ +pub mod fmt; pub mod input; From 7591ecfe4bd1132f1335a8b3f4e6907cbae5b6ed Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 09:30:05 -0300 Subject: [PATCH 04/23] feat(rust): hand-rolled ISO-8601 UTC to epoch --- rust/src/epoch.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++++ rust/src/lib.rs | 1 + 2 files changed, 61 insertions(+) create mode 100644 rust/src/epoch.rs diff --git a/rust/src/epoch.rs b/rust/src/epoch.rs new file mode 100644 index 0000000..3cdcbae --- /dev/null +++ b/rust/src/epoch.rs @@ -0,0 +1,60 @@ +/// Days from civil date to days-since-1970 (Howard Hinnant's algorithm). +/// Correct across leap years; no external date library needed. +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; // [0, 399] + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; // [0, 365] + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096] + era * 146_097 + doe - 719_468 +} + +/// Parse `YYYY-MM-DDTHH:MM:SS` (first 19 chars, UTC) -> epoch seconds. +pub fn iso8601_to_epoch(iso: &str) -> Option { + let b = iso.as_bytes(); + if b.len() < 19 { return None; } + let num = |lo: usize, hi: usize| -> Option { + std::str::from_utf8(&b[lo..hi]).ok()?.parse::().ok() + }; + // Positions: 0123-56-89 T 11:14:17 + if b[4] != b'-' || b[7] != b'-' || (b[10] != b'T' && b[10] != b' ') + || b[13] != b':' || b[16] != b':' { return None; } + let year = num(0, 4)?; + let month = num(5, 7)?; + let day = num(8, 10)?; + let hour = num(11, 13)?; + let min = num(14, 16)?; + let sec = num(17, 19)?; + if !(1..=12).contains(&month) || !(1..=31).contains(&day) + || hour > 23 || min > 59 || sec > 60 { return None; } + let days = days_from_civil(year, month, day); + Some(days * 86_400 + hour * 3600 + min * 60 + sec) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn epoch_unix_zero() { + assert_eq!(iso8601_to_epoch("1970-01-01T00:00:00Z"), Some(0)); + } + #[test] + fn epoch_known_instant() { + // 2026-07-05T18:00:00Z == 1783274400 + assert_eq!(iso8601_to_epoch("2026-07-05T18:00:00Z"), Some(1_783_274_400)); + } + #[test] + fn epoch_ignores_fractional_and_offset_suffix() { + assert_eq!(iso8601_to_epoch("2026-07-05T18:00:00.123456Z"), Some(1_783_274_400)); + } + #[test] + fn epoch_leap_day() { + // 2024-02-29T00:00:00Z == 1709164800 + assert_eq!(iso8601_to_epoch("2024-02-29T00:00:00Z"), Some(1_709_164_800)); + } + #[test] + fn epoch_rejects_garbage() { + assert_eq!(iso8601_to_epoch("not-a-date"), None); + assert_eq!(iso8601_to_epoch(""), None); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 2b497da..bfa5743 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,2 +1,3 @@ +pub mod epoch; pub mod fmt; pub mod input; From 053e983493e349026b18e6a162c939285d54d0dd Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 09:35:29 -0300 Subject: [PATCH 05/23] fix(rust): reject negative hour/min/sec in ISO parser Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/src/epoch.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rust/src/epoch.rs b/rust/src/epoch.rs index 3cdcbae..81f4033 100644 --- a/rust/src/epoch.rs +++ b/rust/src/epoch.rs @@ -26,7 +26,7 @@ pub fn iso8601_to_epoch(iso: &str) -> Option { let min = num(14, 16)?; let sec = num(17, 19)?; if !(1..=12).contains(&month) || !(1..=31).contains(&day) - || hour > 23 || min > 59 || sec > 60 { return None; } + || !(0..=23).contains(&hour) || !(0..=59).contains(&min) || !(0..=60).contains(&sec) { return None; } let days = days_from_civil(year, month, day); Some(days * 86_400 + hour * 3600 + min * 60 + sec) } @@ -57,4 +57,9 @@ mod tests { assert_eq!(iso8601_to_epoch("not-a-date"), None); assert_eq!(iso8601_to_epoch(""), None); } + #[test] + fn epoch_rejects_negative_time_fields() { + assert_eq!(iso8601_to_epoch("1970-01-01T-1:00:00"), None); + assert_eq!(iso8601_to_epoch("1970-01-01T00:-5:00"), None); + } } From 5649043ee9e785c131f8c5a9a922a4717828a8ca Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 09:37:39 -0300 Subject: [PATCH 06/23] feat(rust): per-turn economics (eq, saving) at bash logic parity --- rust/src/economics.rs | 101 ++++++++++++++++++++++++++++++++++++++++++ rust/src/lib.rs | 1 + 2 files changed, 102 insertions(+) create mode 100644 rust/src/economics.rs diff --git a/rust/src/economics.rs b/rust/src/economics.rs new file mode 100644 index 0000000..e9fa7f7 --- /dev/null +++ b/rust/src/economics.rs @@ -0,0 +1,101 @@ +use crate::fmt::Role; +use crate::input::Session; + +pub struct Tier { pub role: Role, pub tokens: u64, pub eq_contrib: f64 } + +pub struct Economics { + pub eq: u64, + pub uncached_eq: u64, + pub saving_pct: i64, + pub tiers: [Tier; 4], + pub any_activity: bool, +} + +pub fn economics(s: &Session, cache_is_1h: bool) -> Economics { + let (read_m, write_m, new_m, out_m) = if s.is_gemini { + (0.25_f64, 1.0, 1.0, 4.0) + } else { + (0.1_f64, if cache_is_1h { 2.0 } else { 1.25 }, 1.0, 5.0) + }; + + let read_eq = s.cur_cread as f64 * read_m; + let write_eq = s.cur_cwrite as f64 * write_m; + let new_eq = s.cur_input as f64 * new_m; + let out_eq = s.cur_output as f64 * out_m; + + // Match bash awk `printf "%d"` (truncation toward zero). + let eq = (read_eq + write_eq + new_eq + out_eq) as u64; + let uncached_eq = + ((s.cur_cread + s.cur_cwrite + s.cur_input) as f64 * new_m + out_eq) as u64; + + let saving_pct = if uncached_eq > 0 { + (100.0 * (uncached_eq as f64 - eq as f64) / uncached_eq as f64) as i64 + } else { 0 }; + + // Thermometer order: cheap -> dear (read, new, write, output). + let tiers = [ + Tier { role: Role::Read, tokens: s.cur_cread, eq_contrib: read_eq }, + Tier { role: Role::New, tokens: s.cur_input, eq_contrib: new_eq }, + Tier { role: Role::Write, tokens: s.cur_cwrite, eq_contrib: write_eq }, + Tier { role: Role::Output, tokens: s.cur_output, eq_contrib: out_eq }, + ]; + + let any_activity = + s.cur_cread > 0 || s.cur_cwrite > 0 || s.cur_input > 0 || s.cur_output > 0; + + Economics { eq, uncached_eq, saving_pct, tiers, any_activity } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::Session; + + fn sess(json: &str) -> Session { Session::from_raw(serde_json::from_str(json).unwrap()) } + + #[test] + fn anthropic_1h_matches_bash() { + // read 86.6k, write 2.2k, new 2, output 2.0k, cache=1h + // eq = 86600*0.1 + 2200*2 + 2*1 + 2000*5 = 8660 + 4400 + 2 + 10000 = 23062 + // unc = (86600+2200+2)*1 + 2000*5 = 88802 + 10000 = 98802 + // save = floor(100*(98802-23062)/98802) = 76 + let s = sess(r#"{"model":{"display_name":"Opus 4.8"},"context_window":{"current_usage": + {"input_tokens":2,"output_tokens":2000,"cache_creation_input_tokens":2200, + "cache_read_input_tokens":86600}}}"#); + let e = economics(&s, true); + assert_eq!(e.eq, 23_062); + assert_eq!(e.uncached_eq, 98_802); + assert_eq!(e.saving_pct, 76); + assert!(e.any_activity); + assert_eq!(e.tiers[0].role as u8, Role::Read as u8); // thermometer order + } + + #[test] + fn anthropic_5m_uses_1_25_write() { + // write mult 1.25 when cache window is 5m + let s = sess(r#"{"context_window":{"current_usage": + {"cache_creation_input_tokens":1000,"output_tokens":0, + "input_tokens":0,"cache_read_input_tokens":0}}}"#); + let e = economics(&s, false); + assert_eq!(e.eq, 1250); // 1000 * 1.25 + } + + #[test] + fn gemini_multipliers() { + // read 0.25, write 1.0, new 1, output 4 + let s = sess(r#"{"model":{"display_name":"Gemini 2.5 Pro"},"context_window":{"current_usage": + {"cache_read_input_tokens":1000,"cache_creation_input_tokens":1000, + "input_tokens":1000,"output_tokens":1000}}}"#); + let e = economics(&s, false); + // eq = 250 + 1000 + 1000 + 4000 = 6250 + assert_eq!(e.eq, 6250); + } + + #[test] + fn zero_activity() { + let s = sess("{}"); + let e = economics(&s, false); + assert!(!e.any_activity); + assert_eq!(e.saving_pct, 0); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index bfa5743..862d507 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,3 +1,4 @@ +pub mod economics; pub mod epoch; pub mod fmt; pub mod input; From 55a38eabc6be139d9192439a0d96c973cc3ec520 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 09:42:25 -0300 Subject: [PATCH 07/23] feat(rust): rate-limit pct/eta/pace/bar at bash logic parity Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/src/lib.rs | 1 + rust/src/ratelimits.rs | 91 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 rust/src/ratelimits.rs diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 862d507..2d518c8 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -2,3 +2,4 @@ pub mod economics; pub mod epoch; pub mod fmt; pub mod input; +pub mod ratelimits; diff --git a/rust/src/ratelimits.rs b/rust/src/ratelimits.rs new file mode 100644 index 0000000..6cefa31 --- /dev/null +++ b/rust/src/ratelimits.rs @@ -0,0 +1,91 @@ +use crate::fmt::Role; +use crate::input::RlRaw; +use crate::epoch::iso8601_to_epoch; + +#[derive(Debug)] +pub enum Pace { None, Fast, VeryFast } + +pub struct RlWindow { pub pct: i64, pub eta_secs: i64, pub pace: Pace } + +/// tokenline.sh:345-363 pace heuristic, extracted for testability. +pub fn pace_of(pct_int: i64, eta_secs: i64, window_secs: i64) -> Pace { + if pct_int < 20 || eta_secs <= 0 || window_secs <= 0 { return Pace::None; } + let mut elapsed = window_secs - eta_secs; + if elapsed < 0 { elapsed = 0; } + if elapsed < window_secs / 10 { return Pace::None; } // need >=10% elapsed + let pace = (pct_int as f64 * window_secs as f64) / (elapsed as f64 * 100.0); + if pace >= 1.5 { Pace::VeryFast } else if pace >= 1.25 { Pace::Fast } else { Pace::None } +} + +pub fn rl_window(raw: &RlRaw, window_secs: i64, now: i64) -> Option { + let pct = raw.used_percentage?; // None => no segment + let pct_int = pct.round() as i64; + let eta_secs = raw.resets_at.as_deref() + .and_then(iso8601_to_epoch) + .map(|r| (r - now).max(0)) + .unwrap_or(0); + Some(RlWindow { pct: pct_int, eta_secs, pace: pace_of(pct_int, eta_secs, window_secs) }) +} + +pub fn rl_bar(pct: i64, width: usize) -> (usize, usize) { + let mut filled = (pct.max(0) as usize * width) / 100; + if filled > width { filled = width; } + (filled, width - filled) +} + +pub fn rl_role(pct: i64) -> (Role, bool) { + if pct >= 90 { (Role::Critical, true) } + else if pct >= 75 { (Role::Critical, false) } + else if pct >= 50 { (Role::Caution, false) } + else if pct >= 25 { (Role::Warn, false) } + else { (Role::Good, false) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::RlRaw; + + fn raw(pct: f64, reset: &str) -> RlRaw { + RlRaw { used_percentage: Some(pct), resets_at: Some(reset.into()) } + } + + #[test] + fn pct_and_eta() { + let now = 1_783_000_000; + // reset 3600s in the future + let w = rl_window(&raw(12.0, "x"), 18000, now).unwrap(); + assert_eq!(w.pct, 12); + assert_eq!(w.eta_secs, 0); // "x" is unparseable -> eta 0 + } + + #[test] + fn pace_flags_fast_burn() { + // 50% used, 14400s to reset in an 18000s window => elapsed 3600s + // pace = (50 * 18000) / (3600 * 100) = 2.5 -> VeryFast + assert!(matches!(pace_of(50, 14_400, 18_000), Pace::VeryFast)); + // 30% used, 14000s to reset => elapsed 4000s + // pace = (30 * 18000) / (4000 * 100) = 1.35 -> Fast + // (brief's original numbers here, eta=15_000, actually yield elapsed=3000s + // and pace=1.8 -> VeryFast; corrected to genuinely land in the Fast band) + assert!(matches!(pace_of(30, 14_000, 18_000), Pace::Fast)); + assert!(matches!(pace_of(10, 1_000, 18_000), Pace::None)); // under 20% -> none + } + + #[test] + fn bar_geometry() { + assert_eq!(rl_bar(0, 10), (0, 10)); + assert_eq!(rl_bar(50, 10), (5, 5)); + assert_eq!(rl_bar(100, 10), (10, 0)); + assert_eq!(rl_bar(250, 10), (10, 0)); // clamped + } + + #[test] + fn color_thresholds() { + assert!(matches!(rl_role(10).0, Role::Good)); + assert!(matches!(rl_role(30).0, Role::Warn)); + assert!(matches!(rl_role(60).0, Role::Caution)); + assert!(matches!(rl_role(80).0, Role::Critical)); + assert!(rl_role(95).1); // blink + } +} From a500c7d9ad4e4afd41e5856e12401012eb0b6470 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 09:47:42 -0300 Subject: [PATCH 08/23] feat(rust): cross-platform runtime dir + consolidated session cache --- rust/src/cache.rs | 113 ++++++++++++++++++++++++++++++++++++++++++++++ rust/src/lib.rs | 1 + 2 files changed, 114 insertions(+) create mode 100644 rust/src/cache.rs diff --git a/rust/src/cache.rs b/rust/src/cache.rs new file mode 100644 index 0000000..c103226 --- /dev/null +++ b/rust/src/cache.rs @@ -0,0 +1,113 @@ +use std::path::{Path, PathBuf}; + +pub struct CacheState { + pub last_ts: Option, + pub ttl: i64, + pub ttl_label: String, + pub last_tokens: u64, +} + +impl Default for CacheState { + fn default() -> Self { + CacheState { last_ts: None, ttl: 300, ttl_label: "5m".into(), last_tokens: 0 } + } +} + +pub fn runtime_dir() -> PathBuf { + let base = platform_base(); + let _ = std::fs::create_dir_all(&base); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700)); + } + if base.is_dir() { base } else { std::env::temp_dir() } +} + +#[cfg(unix)] +fn platform_base() -> PathBuf { + let uid = std::env::var("UID").unwrap_or_default(); // often unset; fall through below + let uid = if uid.is_empty() { unix_uid() } else { uid }; + let root = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into()); + PathBuf::from(root).join(format!("tokenline-{}", uid)) +} + +#[cfg(unix)] +fn unix_uid() -> String { + // SAFETY: getuid() is always safe and never fails. + (unsafe { libc_getuid() }).to_string() +} +#[cfg(unix)] +extern "C" { #[link_name = "getuid"] fn libc_getuid() -> u32; } + +#[cfg(windows)] +fn platform_base() -> PathBuf { + let root = std::env::var("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()); + root.join("tokenline") +} + +fn file_for(dir: &Path, session_id: &str) -> PathBuf { + let id = if session_id.is_empty() { "default" } else { session_id }; + dir.join(format!("session-{}", id)) +} + +pub fn load(dir: &Path, session_id: &str) -> CacheState { + let mut st = CacheState::default(); + let Ok(raw) = std::fs::read_to_string(file_for(dir, session_id)) else { return st; }; + let parts: Vec<&str> = raw.split_whitespace().collect(); + if parts.len() >= 4 { + st.last_ts = parts[0].parse::().ok(); + if let Ok(t) = parts[1].parse::() { st.ttl = t; } + st.ttl_label = parts[2].to_string(); + if let Ok(tk) = parts[3].parse::() { st.last_tokens = tk; } + } + st +} + +pub fn store(dir: &Path, session_id: &str, st: &CacheState) { + let line = format!("{} {} {} {}", + st.last_ts.unwrap_or(0), st.ttl, st.ttl_label, st.last_tokens); + let _ = std::fs::write(file_for(dir, session_id), line); // best-effort +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch() -> std::path::PathBuf { + let mut d = std::env::temp_dir(); + // unique-ish without rand/time crates: process id + d.push(format!("tokenline-test-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn roundtrip() { + let dir = scratch(); + let st = CacheState { last_ts: Some(1_783_274_400), ttl: 3600, + ttl_label: "1h".into(), last_tokens: 88_802 }; + store(&dir, "sess1", &st); + let got = load(&dir, "sess1"); + assert_eq!(got.last_ts, Some(1_783_274_400)); + assert_eq!(got.ttl, 3600); + assert_eq!(got.ttl_label, "1h"); + assert_eq!(got.last_tokens, 88_802); + } + + #[test] + fn missing_file_defaults() { + let dir = scratch(); + let got = load(&dir, "does-not-exist"); + assert_eq!(got.last_ts, None); + assert_eq!(got.ttl_label, "5m"); // default window + } + + #[test] + fn runtime_dir_exists() { + let d = runtime_dir(); + assert!(d.exists()); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 2d518c8..161c863 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,3 +1,4 @@ +pub mod cache; pub mod economics; pub mod epoch; pub mod fmt; From a5d5ad8fbc0d997db8bc48e45fd4b5fa619ad589 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 10:00:43 -0300 Subject: [PATCH 09/23] feat(rust): cache TTL/countdown timer with transcript read --- rust/src/cachetimer.rs | 162 +++++++++++++++++++++++++++++++++++++++++ rust/src/lib.rs | 1 + 2 files changed, 163 insertions(+) create mode 100644 rust/src/cachetimer.rs diff --git a/rust/src/cachetimer.rs b/rust/src/cachetimer.rs new file mode 100644 index 0000000..f4ebd3b --- /dev/null +++ b/rust/src/cachetimer.rs @@ -0,0 +1,162 @@ +use std::path::Path; +use crate::cache; +use crate::epoch::iso8601_to_epoch; +use crate::input::Session; + +#[derive(Debug)] +pub enum Warmth { Warm, Cooling, Cold } + +pub struct CacheInfo { + pub ttl_label: String, + pub remaining_secs: i64, + pub warmth: Warmth, + pub is_1h: bool, +} + +/// Read the last assistant/PLANNER turn's (timestamp, ephemeral_5m, ephemeral_1h) +/// from the tail of the transcript. Returns (last_ts, e5m, e1h). +fn scan_transcript(path: &str) -> (Option, u64, u64) { + let Ok(content) = std::fs::read_to_string(path) else { return (None, 0, 0); }; + // Tail ~200 lines (tokenline.sh:199). Scan from the end for the last match. + for line in content.lines().rev().take(200) { + let Ok(v) = serde_json::from_str::(line) else { continue; }; + let t = v.get("type").and_then(|t| t.as_str()).unwrap_or(""); + if t != "assistant" && t != "PLANNER_RESPONSE" { continue; } + let iso = v.get("timestamp").and_then(|x| x.as_str()) + .or_else(|| v.get("created_at").and_then(|x| x.as_str())); + let cc = v.pointer("/message/usage/cache_creation"); + let e5m = cc.and_then(|c| c.get("ephemeral_5m_input_tokens")).and_then(|x| x.as_u64()).unwrap_or(0); + let e1h = cc.and_then(|c| c.get("ephemeral_1h_input_tokens")).and_then(|x| x.as_u64()).unwrap_or(0); + return (iso.and_then(iso8601_to_epoch), e5m, e1h); + } + (None, 0, 0) +} + +fn file_mtime(path: &str) -> Option { + let meta = std::fs::metadata(path).ok()?; + let mt = meta.modified().ok()?; + let secs = mt.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); + Some(secs as i64) +} + +pub fn cache_info(s: &Session, now: i64, dir: &Path) -> CacheInfo { + let mut state = cache::load(dir, &s.session_id); + + // Turn detection: token count changed => stamp `now` (tokenline.sh:182-188) + if s.tokens_used != state.last_tokens { + state.last_ts = Some(now); + state.last_tokens = s.tokens_used; + } + + // Transcript last-turn timestamp + ephemeral flags (tokenline.sh:195-214) + let (mut last_ts, e5m, e1h) = if !s.transcript_path.is_empty() { + let (ts, a, b) = scan_transcript(&s.transcript_path); + let ts = ts.or_else(|| file_mtime(&s.transcript_path)); // mtime fallback + (ts, a, b) + } else { (None, 0, 0) }; + + // Prefer cached ts if newer or transcript ts missing (tokenline.sh:216-223) + if let Some(cached) = state.last_ts { + if last_ts.is_none_or(|t| cached > t) { last_ts = Some(cached); } + } + // Always have a timestamp (tokenline.sh:225-229) + let last_ts = last_ts.unwrap_or(now); + state.last_ts = Some(last_ts); + + // TTL window determination (tokenline.sh:231-250) + let (ttl, ttl_label) = if s.is_gemini { + (300, "5m") + } else if e1h > 0 { + (3600, "1h") + } else if e5m > 0 { + (300, "5m") + } else { + // fall back to previously-determined session ttl + (state.ttl, if state.ttl == 3600 { "1h" } else { "5m" }) + }; + state.ttl = ttl; + state.ttl_label = ttl_label.to_string(); + cache::store(dir, &s.session_id, &state); + + let elapsed = now - last_ts; + let remaining = ttl - elapsed; + // pct10 == remaining*10/ttl (tokenline.sh:260) + let pct10 = if ttl > 0 { remaining * 10 / ttl } else { 0 }; + let warmth = if remaining <= 0 { Warmth::Cold } + else if pct10 < 2 { Warmth::Cooling } // < 20% left + else { Warmth::Warm }; + + CacheInfo { ttl_label: ttl_label.to_string(), remaining_secs: remaining, warmth, is_1h: ttl == 3600 } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::Session; + + fn scratch() -> std::path::PathBuf { + let mut d = std::env::temp_dir(); + d.push(format!("tl-ct-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d + } + fn sess(json: &str) -> Session { Session::from_raw(serde_json::from_str(json).unwrap()) } + + #[test] + fn fresh_session_starts_warm_at_full_ttl() { + let dir = scratch(); + // no transcript, no cache => last_ts defaults to now => remaining == ttl + let s = sess(r#"{"session_id":"warm1","context_window":{"current_usage": + {"cache_read_input_tokens":100}}}"#); + let now = 1_783_000_000; + let ci = cache_info(&s, now, &dir); + assert_eq!(ci.ttl_label, "5m"); // no ephemeral fields => default 5m + assert!(ci.remaining_secs > 290 && ci.remaining_secs <= 300); + assert!(matches!(ci.warmth, Warmth::Warm)); + } + + #[test] + fn transcript_1h_window_and_countdown() { + let dir = scratch(); + // transcript whose last assistant turn is 100s ago and has ephemeral_1h > 0 + let tp = dir.join("t1.jsonl"); + // Must be a single physical line: scan_transcript reads JSONL (one JSON object + // per line), so a raw string spanning multiple source lines would embed real + // newlines and split this into unparsable fragments. + std::fs::write(&tp, format!( + "{}\n", + r#"{"type":"assistant","timestamp":"2026-07-05T00:00:00Z","message":{"usage":{"cache_creation":{"ephemeral_1h_input_tokens":10,"ephemeral_5m_input_tokens":0}}}}"# + )).unwrap(); + let turn_epoch = crate::epoch::iso8601_to_epoch("2026-07-05T00:00:00Z").unwrap(); + let now = turn_epoch + 100; // 100s after the turn + let s = sess(&format!(r#"{{"session_id":"h1","transcript_path":"{}", + "context_window":{{"current_usage":{{"cache_read_input_tokens":100}}}}}}"#, + tp.display())); + // Prime last_tokens to match s.tokens_used so turn detection does NOT fire this + // poll (tokenline.sh:185 fires on a *change*; a truly first-ever poll would stamp + // `now` and clobber the transcript-derived ts, which is not what this test probes). + // This models steady-state polling: no new turn since last render, so the last + // real turn time must come from the transcript, not from "now". + crate::cache::store(&dir, "h1", &crate::cache::CacheState { + last_ts: None, ttl: 300, ttl_label: "5m".into(), last_tokens: 100 }); + let ci = cache_info(&s, now, &dir); + assert_eq!(ci.ttl_label, "1h"); + assert!(ci.is_1h); + assert!((ci.remaining_secs - 3500).abs() <= 2); // 3600 - 100 + assert!(matches!(ci.warmth, Warmth::Warm)); + } + + #[test] + fn expired_is_cold() { + let dir = scratch(); + let s = sess(r#"{"session_id":"cold1","context_window":{"current_usage": + {"cache_read_input_tokens":100}}}"#); + // Prime the cache with an old timestamp + 5m ttl + crate::cache::store(&dir, "cold1", &crate::cache::CacheState{ + last_ts: Some(1_000_000), ttl: 300, ttl_label: "5m".into(), last_tokens: 100 }); + let now = 1_000_000 + 400; // 400s later, ttl 300 => expired + let ci = cache_info(&s, now, &dir); + assert!(ci.remaining_secs <= 0); + assert!(matches!(ci.warmth, Warmth::Cold)); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 161c863..cddef2c 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,4 +1,5 @@ pub mod cache; +pub mod cachetimer; pub mod economics; pub mod epoch; pub mod fmt; From f875835895fdd56c9aeadb058014e8fcfba9fe34 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 10:13:40 -0300 Subject: [PATCH 10/23] feat(rust): PULSE+ render (Normal density) + golden fixtures Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/src/lib.rs | 3 + rust/src/render.rs | 151 ++++++++++++++++++++++++ rust/tests/fixtures/healthy_claude.json | 13 ++ rust/tests/fixtures/healthy_claude.txt | 3 + rust/tests/golden.rs | 57 +++++++++ 5 files changed, 227 insertions(+) create mode 100644 rust/src/render.rs create mode 100644 rust/tests/fixtures/healthy_claude.json create mode 100644 rust/tests/fixtures/healthy_claude.txt create mode 100644 rust/tests/golden.rs diff --git a/rust/src/lib.rs b/rust/src/lib.rs index cddef2c..33c96be 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -5,3 +5,6 @@ pub mod epoch; pub mod fmt; pub mod input; pub mod ratelimits; +pub mod render; + +pub use render::{render, Density}; diff --git a/rust/src/render.rs b/rust/src/render.rs new file mode 100644 index 0000000..37f0e92 --- /dev/null +++ b/rust/src/render.rs @@ -0,0 +1,151 @@ +use std::path::Path; +use crate::input::{RawInput, Session, Client}; +use crate::fmt::{Role, fmt_k, fmt_eta}; +use crate::economics::economics; +use crate::ratelimits::{rl_window, rl_bar, rl_role, Pace, RlWindow}; +use crate::cachetimer::{cache_info, Warmth}; + +#[derive(Clone, Copy)] +pub enum Density { Compact, Normal, Verbose } + +struct Buf(String); +impl Buf { + fn new() -> Self { Buf(String::new()) } + fn seg(&mut self, role: Role, text: &str, bold: bool, blink: bool) { + self.0.push_str(&role.paint(text, bold, blink)); + } + fn take(self) -> String { self.0 } +} + +/// Public entry. v1: always renders Normal (PULSE+); other densities fall back. +pub fn render(input: RawInput, now: i64, cache_dir: &Path, _density: Density) -> String { + let s = Session::from_raw(input); + render_normal(&s, now, cache_dir) +} + +fn render_normal(s: &Session, now: i64, cache_dir: &Path) -> String { + let ci = cache_info(s, now, cache_dir); + let eco = economics(s, ci.is_1h); + let mut out = String::new(); + + // ---- Line 1: model · ctx · cache ------------------------------------- + let mut l1 = Buf::new(); + // model (with Antigravity brand) + if s.cli_client == Client::Antigravity { + l1.seg(Role::Accent, "🌌 Antigravity", false, false); + l1.seg(Role::Muted, &format!(" ({})", s.model), false, false); + } else { + l1.seg(Role::Muted, &s.model, false, false); + } + // ctx (sparkline + %) + if let Some(pct) = s.used_pct { + let pct = pct.round() as i64; + let (role, bold) = ctx_role(pct); + l1.seg(Role::Faint, " · ", false, false); + l1.seg(Role::Muted, "ctx ", false, false); + l1.seg(role, &spark(pct), bold, false); + l1.seg(role, &format!(" {}%", pct), bold, false); + } + // cache (heartbeat + countdown + drain bar + word) + l1.seg(Role::Faint, " · ", false, false); + let (cache_role, blink, word) = warmth_style(&ci.warmth); + l1.seg(cache_role, "♥ ", false, blink); + l1.seg(Role::Muted, "cache ", false, false); + l1.seg(cache_role, &clock(ci.remaining_secs), true, blink); + l1.seg(cache_role, &format!(" {} ", drain_bar(ci.remaining_secs, ttl_of(&ci))), false, false); + l1.seg(cache_role, word, blink, blink); + out.push_str(&l1.take()); out.push('\n'); + + // ---- Line 2: cost thermometer (only when a turn happened) ------------ + if eco.any_activity { + let mut l2 = Buf::new(); + l2.seg(Role::Muted, "cost ", false, false); + for (glyph, role) in thermometer(&eco) { + l2.seg(role, &glyph, false, false); + } + l2.seg(Role::Accent, &format!(" {}", fmt_k(eco.eq)), true, false); + l2.seg(Role::Faint, &format!(" / {} full", fmt_k(eco.uncached_eq)), false, false); + l2.seg(Role::Faint, " · ", false, false); + let save_role = save_role(eco.saving_pct); + l2.seg(save_role, &format!("↓{}% saved", eco.saving_pct), true, false); + out.push_str(&l2.take()); out.push('\n'); + } + + // ---- Line 3: rate limits (Anthropic only) ---------------------------- + if !s.is_gemini { + let w5 = s.rl_5h.as_ref().and_then(|r| rl_window(r, 18_000, now)); + let w7 = s.rl_7d.as_ref().and_then(|r| rl_window(r, 604_800, now)); + if w5.is_some() || w7.is_some() { + let mut l3 = Buf::new(); + if let Some(w) = &w5 { rl_segment(&mut l3, "5h", w); } + if let Some(w) = &w7 { + if w5.is_some() { l3.seg(Role::Faint, " ", false, false); } + rl_segment(&mut l3, "7d", w); + } + out.push_str(&l3.take()); out.push('\n'); + } + } + + out +} + +fn rl_segment(b: &mut Buf, label: &str, w: &RlWindow) { + let (role, blink) = rl_role(w.pct); + let marker = if matches!(w.pace, Pace::VeryFast) || w.pct >= 75 { "▲ " } else { "▸ " }; + b.seg(role, marker, false, blink); + b.seg(role, &format!("{} ", label), false, false); + let (filled, empty) = rl_bar(w.pct, 10); + b.seg(role, &"█".repeat(filled), false, false); + b.seg(Role::Faint, &"░".repeat(empty), false, false); + b.seg(role, &format!(" {}% ", w.pct), false, blink); + match w.pace { + Pace::VeryFast => b.seg(role, &format!("⚡ pace · empties {} ", fmt_eta(w.eta_secs)), false, false), + Pace::Fast => b.seg(role, "⚡ pace ", false, false), + Pace::None => {} + } + if w.eta_secs > 0 { + b.seg(Role::Faint, &format!("· {} left", fmt_eta(w.eta_secs)), false, false); + } +} + +// ---- small helpers ------------------------------------------------------ +fn ctx_role(pct: i64) -> (Role, bool) { + if pct >= 80 { (Role::Critical, true) } + else if pct >= 50 { (Role::Warn, false) } + else { (Role::Good, false) } +} +fn spark(pct: i64) -> String { + // 2-cell rising meter: ▁▂▃▄▅▆▇█ picked by 8ths. + let blocks = ['▁','▂','▃','▄','▅','▆','▇','█']; + let idx = ((pct.clamp(0, 100) * 8) / 100).clamp(0, 7) as usize; + format!("{}{}", blocks[idx], blocks[idx.min(7)]) +} +fn warmth_style(w: &Warmth) -> (Role, bool, &'static str) { + match w { + Warmth::Warm => (Role::Cache, false, "warm"), + Warmth::Cooling => (Role::Warn, false, "cooling"), + Warmth::Cold => (Role::Critical, true, "COLD"), + } +} +fn clock(remaining: i64) -> String { + let r = remaining.max(0); + format!("{}:{:02}", r / 60, r % 60) +} +fn ttl_of(ci: &crate::cachetimer::CacheInfo) -> i64 { if ci.is_1h { 3600 } else { 300 } } +fn drain_bar(remaining: i64, ttl: i64) -> String { + let filled = if ttl > 0 { (remaining.max(0) * 10 / ttl).clamp(0, 10) as usize } else { 0 }; + format!("{}{}", "█".repeat(filled), "░".repeat(10 - filled)) +} +fn save_role(pct: i64) -> Role { + if pct >= 70 { Role::Good } else if pct >= 50 { Role::Warn } else { Role::Caution } +} +/// Stacked cost thermometer: widths ∝ eq-contribution over ~11 cells, order read·new·write·output. +fn thermometer(eco: &crate::economics::Economics) -> Vec<(String, Role)> { + let total: f64 = eco.tiers.iter().map(|t| t.eq_contrib).sum::().max(1.0); + let cells = 11.0; + eco.tiers.iter().map(|t| { + let n = (t.eq_contrib / total * cells).round() as usize; + let glyph = if n == 0 && t.eq_contrib > 0.0 { "·".to_string() } else { "▉".repeat(n) }; + (glyph, t.role) + }).collect() +} diff --git a/rust/tests/fixtures/healthy_claude.json b/rust/tests/fixtures/healthy_claude.json new file mode 100644 index 0000000..fe0debd --- /dev/null +++ b/rust/tests/fixtures/healthy_claude.json @@ -0,0 +1,13 @@ +{ + "model": {"display_name": "Opus 4.8"}, + "context_window": { + "used_percentage": 9.0, "context_window_size": 1000000, + "current_usage": {"input_tokens": 2, "output_tokens": 2000, + "cache_creation_input_tokens": 2200, "cache_read_input_tokens": 86600} + }, + "transcript_path": "", "session_id": "golden-healthy", + "rate_limits": { + "five_hour": {"used_percentage": 12.0, "resets_at": "2026-07-05T18:00:00Z"}, + "seven_day": {"used_percentage": 64.0, "resets_at": "2026-07-06T12:00:00Z"} + } +} diff --git a/rust/tests/fixtures/healthy_claude.txt b/rust/tests/fixtures/healthy_claude.txt new file mode 100644 index 0000000..5a85201 --- /dev/null +++ b/rust/tests/fixtures/healthy_claude.txt @@ -0,0 +1,3 @@ +Opus 4.8 · ctx ▁▁ 9% · ♥ cache 5:00 ██████████ warm +cost ▉▉▉▉·▉▉▉▉▉▉ 21.4k / 98.8k full · ↓78% saved +▸ 5h █░░░░░░░░░ 12% · 3d4h left ▸ 7d ██████░░░░ 64% ⚡ pace · 3d22h left diff --git a/rust/tests/golden.rs b/rust/tests/golden.rs new file mode 100644 index 0000000..1771587 --- /dev/null +++ b/rust/tests/golden.rs @@ -0,0 +1,57 @@ +use std::path::PathBuf; +use tokenline::{render, Density}; +use tokenline::input::RawInput; + +fn strip_ansi(s: &str) -> String { + let mut out = String::new(); + let b = s.as_bytes(); + let mut i = 0; + while i < b.len() { + if b[i] == 0x1b { + while i < b.len() && b[i] != b'm' { i += 1; } + i += 1; // skip 'm' + } else { out.push(b[i] as char); i += 1; } + } + out +} + +fn fixtures_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") } + +fn cache_scratch() -> PathBuf { + let d = std::env::temp_dir().join(format!("tl-golden-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn healthy_claude_matches_golden() { + let raw: RawInput = serde_json::from_str( + &std::fs::read_to_string(fixtures_dir().join("healthy_claude.json")).unwrap()).unwrap(); + // FIXED now so the cache countdown is deterministic. + let now = 1_783_000_000; + let out = render(raw, now, &cache_scratch(), Density::Normal); + let got = strip_ansi(&out); + let want = std::fs::read_to_string(fixtures_dir().join("healthy_claude.txt")).unwrap(); + assert_eq!(got, want, "\n--- got ---\n{}\n--- want ---\n{}", got, want); +} + +#[test] +fn gemini_hides_rate_limits() { + let raw: RawInput = serde_json::from_str( + r#"{"model":{"display_name":"Gemini 2.5 Pro"}, + "context_window":{"used_percentage":9.0,"context_window_size":1000000, + "current_usage":{"cache_read_input_tokens":100,"output_tokens":50}}}"#).unwrap(); + let out = strip_ansi(&render(raw, 1_783_000_000, &cache_scratch(), Density::Normal)); + assert!(!out.contains("5h"), "Gemini has no rate limits"); + assert!(!out.contains("7d")); +} + +#[test] +fn no_activity_hides_cost_line() { + let raw: RawInput = serde_json::from_str( + r#"{"model":{"display_name":"Opus 4.8"}, + "context_window":{"used_percentage":1.0}}"#).unwrap(); + let out = strip_ansi(&render(raw, 1_783_000_000, &cache_scratch(), Density::Normal)); + assert!(!out.contains("cost "), "no turn => no cost line"); +} + From 64b666d433d7ff3bb6a232be82e0f0cd1c2173bf Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 10:22:05 -0300 Subject: [PATCH 11/23] fix(rust): dedup VeryFast pace ETA; UTF-8-correct strip_ansi + clean golden Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/src/render.rs | 2 +- rust/tests/fixtures/healthy_claude.txt | 6 +++--- rust/tests/golden.rs | 29 +++++++++++++++++++------- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/rust/src/render.rs b/rust/src/render.rs index 37f0e92..7be2fb9 100644 --- a/rust/src/render.rs +++ b/rust/src/render.rs @@ -99,7 +99,7 @@ fn rl_segment(b: &mut Buf, label: &str, w: &RlWindow) { b.seg(Role::Faint, &"░".repeat(empty), false, false); b.seg(role, &format!(" {}% ", w.pct), false, blink); match w.pace { - Pace::VeryFast => b.seg(role, &format!("⚡ pace · empties {} ", fmt_eta(w.eta_secs)), false, false), + Pace::VeryFast => b.seg(role, "⚡ pace!! ", false, false), Pace::Fast => b.seg(role, "⚡ pace ", false, false), Pace::None => {} } diff --git a/rust/tests/fixtures/healthy_claude.txt b/rust/tests/fixtures/healthy_claude.txt index 5a85201..4faae54 100644 --- a/rust/tests/fixtures/healthy_claude.txt +++ b/rust/tests/fixtures/healthy_claude.txt @@ -1,3 +1,3 @@ -Opus 4.8 · ctx ▁▁ 9% · ♥ cache 5:00 ██████████ warm -cost ▉▉▉▉·▉▉▉▉▉▉ 21.4k / 98.8k full · ↓78% saved -▸ 5h █░░░░░░░░░ 12% · 3d4h left ▸ 7d ██████░░░░ 64% ⚡ pace · 3d22h left +Opus 4.8 · ctx ▁▁ 9% · ♥ cache 5:00 ██████████ warm +cost ▉▉▉▉·▉▉▉▉▉▉ 21.4k / 98.8k full · ↓78% saved +▸ 5h █░░░░░░░░░ 12% · 3d4h left ▸ 7d ██████░░░░ 64% ⚡ pace · 3d22h left diff --git a/rust/tests/golden.rs b/rust/tests/golden.rs index 1771587..e21c240 100644 --- a/rust/tests/golden.rs +++ b/rust/tests/golden.rs @@ -4,13 +4,13 @@ use tokenline::input::RawInput; fn strip_ansi(s: &str) -> String { let mut out = String::new(); - let b = s.as_bytes(); - let mut i = 0; - while i < b.len() { - if b[i] == 0x1b { - while i < b.len() && b[i] != b'm' { i += 1; } - i += 1; // skip 'm' - } else { out.push(b[i] as char); i += 1; } + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\x1b' { + for c2 in chars.by_ref() { if c2 == 'm' { break; } } // skip ESC…m + } else { + out.push(c); + } } out } @@ -55,3 +55,18 @@ fn no_activity_hides_cost_line() { assert!(!out.contains("cost "), "no turn => no cost line"); } +#[test] +fn very_fast_pace_shows_eta_once() { + // now = 1_783_000_000; resets_at = now + 9000s = 1_783_009_000 = 2026-07-02T16:16:40Z. + // window 18000s, elapsed = 18000 - 9000 = 9000 (>= 10% of window). + // pace = 88 * 18000 / (9000 * 100) = 1.76 -> Pace::VeryFast. + let raw: RawInput = serde_json::from_str( + r#"{"model":{"display_name":"Opus 4.8"}, + "context_window":{"used_percentage":9.0}, + "rate_limits":{"five_hour":{"used_percentage":88.0,"resets_at":"2026-07-02T16:16:40Z"}}}"#).unwrap(); + let out = strip_ansi(&render(raw, 1_783_000_000, &cache_scratch(), Density::Normal)); + assert!(out.contains("⚡ pace!!"), "expected VeryFast pace flag:\n{}", out); + assert!(!out.contains("empties"), "ETA must not be duplicated via 'empties':\n{}", out); + assert_eq!(out.matches("left").count(), 1, "ETA must appear exactly once:\n{}", out); +} + From 2723f2b3dbcb7714b6eb3dfb1d8799169f6b988b Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 10:25:28 -0300 Subject: [PATCH 12/23] feat(rust): never-crash main (catch_unwind, silent exit 0) --- rust/src/main.rs | 31 +++++++++++++++++++++++++------ rust/tests/never_crash.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 rust/tests/never_crash.rs diff --git a/rust/src/main.rs b/rust/src/main.rs index 6c1f95e..2ca878e 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,11 +1,30 @@ use std::io::Read; -use tokenline::input::{RawInput, Session}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokenline::{render, Density}; +use tokenline::cache::runtime_dir; +use tokenline::input::RawInput; fn main() { - let mut buf = String::new(); - if std::io::stdin().read_to_string(&mut buf).is_err() { return; } - match serde_json::from_str::(&buf) { - Ok(raw) => println!("{}", Session::from_raw(raw).model), - Err(_) => {} // silent no-op on malformed input + // Any panic anywhere becomes empty output + exit(0). Never disturb the host. + std::panic::set_hook(Box::new(|_| {})); // suppress backtrace to stderr + let result = std::panic::catch_unwind(run); + // run() returns the string to print (empty on any handled failure). + let line = result.unwrap_or_default(); + if !line.is_empty() { + print!("{}", line); } + std::process::exit(0); +} + +fn run() -> String { + let mut buf = String::new(); + if std::io::stdin().read_to_string(&mut buf).is_err() { return String::new(); } + let raw: RawInput = match serde_json::from_str(&buf) { + Ok(r) => r, + Err(_) => return String::new(), // malformed => silent no-op + }; + let now = SystemTime::now().duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64).unwrap_or(0); + let dir = runtime_dir(); + render(raw, now, &dir, Density::Normal) } diff --git a/rust/tests/never_crash.rs b/rust/tests/never_crash.rs new file mode 100644 index 0000000..5683ca4 --- /dev/null +++ b/rust/tests/never_crash.rs @@ -0,0 +1,33 @@ +use std::io::Write; +use std::process::{Command, Stdio}; + +fn run(stdin: &str) -> (i32, String) { + let mut child = Command::new(env!("CARGO_BIN_EXE_tokenline")) + .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()) + .spawn().unwrap(); + child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap(); + let out = child.wait_with_output().unwrap(); + (out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).into_owned()) +} + +#[test] +fn malformed_json_exits_zero_silently() { + let (code, out) = run("this is not json"); + assert_eq!(code, 0); + assert!(out.trim().is_empty()); +} + +#[test] +fn empty_stdin_exits_zero() { + let (code, out) = run(""); + assert_eq!(code, 0); + assert!(out.trim().is_empty()); +} + +#[test] +fn valid_input_renders_and_exits_zero() { + let (code, out) = run(r#"{"model":{"display_name":"Opus 4.8"}, + "context_window":{"used_percentage":9.0}}"#); + assert_eq!(code, 0); + assert!(out.contains("Opus 4.8")); +} From 1db052c6b752594faa51db9f3cb1ef3ac776ea88 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 10:33:21 -0300 Subject: [PATCH 13/23] fix(rust): never panic on broken pipe (write_all not print!); strengthen e2e assert Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/src/main.rs | 5 ++++- rust/tests/never_crash.rs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/rust/src/main.rs b/rust/src/main.rs index 2ca878e..9250dad 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,4 +1,5 @@ use std::io::Read; +use std::io::Write; use std::time::{SystemTime, UNIX_EPOCH}; use tokenline::{render, Density}; use tokenline::cache::runtime_dir; @@ -11,7 +12,9 @@ fn main() { // run() returns the string to print (empty on any handled failure). let line = result.unwrap_or_default(); if !line.is_empty() { - print!("{}", line); + let mut out = std::io::stdout(); + let _ = out.write_all(line.as_bytes()); + let _ = out.flush(); } std::process::exit(0); } diff --git a/rust/tests/never_crash.rs b/rust/tests/never_crash.rs index 5683ca4..5b0496d 100644 --- a/rust/tests/never_crash.rs +++ b/rust/tests/never_crash.rs @@ -27,7 +27,10 @@ fn empty_stdin_exits_zero() { #[test] fn valid_input_renders_and_exits_zero() { let (code, out) = run(r#"{"model":{"display_name":"Opus 4.8"}, - "context_window":{"used_percentage":9.0}}"#); + "context_window":{"used_percentage":9.0, + "current_usage":{"input_tokens":2,"output_tokens":2000, + "cache_creation_input_tokens":2200,"cache_read_input_tokens":86600}}}"#); assert_eq!(code, 0); assert!(out.contains("Opus 4.8")); + assert!(out.contains("cost "), "expected the full PULSE+ render (cost line), got: {}", out); } From e1b708423739b1215b2ce1f9895448a6041d41e6 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 10:47:23 -0300 Subject: [PATCH 14/23] fix(rust): normalize Windows path separators; de-collide test scratch dirs Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/src/input.rs | 16 +++++++++++++++- rust/tests/golden.rs | 12 ++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/rust/src/input.rs b/rust/src/input.rs index fa9d487..9b97912 100644 --- a/rust/src/input.rs +++ b/rust/src/input.rs @@ -65,7 +65,9 @@ impl Session { let tokens_used = u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens; // Subagent transcript -> parent session (tokenline.sh:123-125) - let mut transcript_path = r.transcript_path; + // Normalize Windows backslashes once, up front, so all contains()/replace() + // logic below (written for forward slashes) works on both platforms. + let mut transcript_path = r.transcript_path.replace('\\', "/"); if transcript_path.contains("/subagents/") { if let Some(parent) = std::path::Path::new(&transcript_path) .parent().and_then(|p| p.parent()) @@ -156,4 +158,16 @@ mod tests { assert_eq!(s.cli_client, Client::Antigravity); assert!(s.transcript_path.contains("/antigravity-cli/")); } + + #[test] + fn normalizes_windows_backslash_paths() { + // Backslash subagent path resolves to the parent .jsonl, same as the unix case. + let raw: RawInput = serde_json::from_str( + r#"{"transcript_path":"C:\\Users\\x\\antigravity\\p\\subagents\\s\\t.jsonl"}"#).unwrap(); + let s = Session::from_raw(raw); + assert_eq!(s.cli_client, Client::Antigravity); + assert!(s.transcript_path.contains("/antigravity-cli/")); + assert!(s.transcript_path.ends_with(".jsonl")); + assert!(!s.transcript_path.contains("/subagents/")); + } } diff --git a/rust/tests/golden.rs b/rust/tests/golden.rs index e21c240..ccb33bd 100644 --- a/rust/tests/golden.rs +++ b/rust/tests/golden.rs @@ -17,8 +17,8 @@ fn strip_ansi(s: &str) -> String { fn fixtures_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") } -fn cache_scratch() -> PathBuf { - let d = std::env::temp_dir().join(format!("tl-golden-{}", std::process::id())); +fn cache_scratch(label: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("tl-golden-{}-{}", std::process::id(), label)); std::fs::create_dir_all(&d).unwrap(); d } @@ -29,7 +29,7 @@ fn healthy_claude_matches_golden() { &std::fs::read_to_string(fixtures_dir().join("healthy_claude.json")).unwrap()).unwrap(); // FIXED now so the cache countdown is deterministic. let now = 1_783_000_000; - let out = render(raw, now, &cache_scratch(), Density::Normal); + let out = render(raw, now, &cache_scratch("healthy"), Density::Normal); let got = strip_ansi(&out); let want = std::fs::read_to_string(fixtures_dir().join("healthy_claude.txt")).unwrap(); assert_eq!(got, want, "\n--- got ---\n{}\n--- want ---\n{}", got, want); @@ -41,7 +41,7 @@ fn gemini_hides_rate_limits() { r#"{"model":{"display_name":"Gemini 2.5 Pro"}, "context_window":{"used_percentage":9.0,"context_window_size":1000000, "current_usage":{"cache_read_input_tokens":100,"output_tokens":50}}}"#).unwrap(); - let out = strip_ansi(&render(raw, 1_783_000_000, &cache_scratch(), Density::Normal)); + let out = strip_ansi(&render(raw, 1_783_000_000, &cache_scratch("gemini"), Density::Normal)); assert!(!out.contains("5h"), "Gemini has no rate limits"); assert!(!out.contains("7d")); } @@ -51,7 +51,7 @@ fn no_activity_hides_cost_line() { let raw: RawInput = serde_json::from_str( r#"{"model":{"display_name":"Opus 4.8"}, "context_window":{"used_percentage":1.0}}"#).unwrap(); - let out = strip_ansi(&render(raw, 1_783_000_000, &cache_scratch(), Density::Normal)); + let out = strip_ansi(&render(raw, 1_783_000_000, &cache_scratch("noact"), Density::Normal)); assert!(!out.contains("cost "), "no turn => no cost line"); } @@ -64,7 +64,7 @@ fn very_fast_pace_shows_eta_once() { r#"{"model":{"display_name":"Opus 4.8"}, "context_window":{"used_percentage":9.0}, "rate_limits":{"five_hour":{"used_percentage":88.0,"resets_at":"2026-07-02T16:16:40Z"}}}"#).unwrap(); - let out = strip_ansi(&render(raw, 1_783_000_000, &cache_scratch(), Density::Normal)); + let out = strip_ansi(&render(raw, 1_783_000_000, &cache_scratch("pace"), Density::Normal)); assert!(out.contains("⚡ pace!!"), "expected VeryFast pace flag:\n{}", out); assert!(!out.contains("empties"), "ETA must not be duplicated via 'empties':\n{}", out); assert_eq!(out.matches("left").count(), 1, "ETA must appear exactly once:\n{}", out); From 03736d0989f865fa8455787defac905b11c970a1 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 11:16:53 -0300 Subject: [PATCH 15/23] =?UTF-8?q?docs(plan):=20add=20Plan=202=20=E2=80=94?= =?UTF-8?q?=20Rust=20distribution=20(cargo-dist=20+=20CI=20+=20edition=202?= =?UTF-8?q?024)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-05-rust-tokenline-distribution.md | 451 ++++++++++++++++++ 1 file changed, 451 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-05-rust-tokenline-distribution.md diff --git a/docs/superpowers/plans/2026-07-05-rust-tokenline-distribution.md b/docs/superpowers/plans/2026-07-05-rust-tokenline-distribution.md new file mode 100644 index 0000000..3f50b3f --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-rust-tokenline-distribution.md @@ -0,0 +1,451 @@ +# tokenline Rust Distribution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the `tokenline` Rust binary with a cross-platform release pipeline (cargo-dist → GitHub Release assets + `curl|sh`/`irm|iex` installers) and a Rust CI matrix that builds, lints, tests, and runs the binary on Linux/macOS/Windows plus a pinned MSRV floor. + +**Architecture:** This is Plan 2 of the Rust rewrite (Plan 1 = the core binary, complete on branch `feat/rust-statusline`). The crate lives in `rust/`; the git root is the repo root, so cargo-dist is driven from a `dist-workspace.toml` at the repo root pointing at the `rust/` member — that keeps `.github/workflows/` at the git root while letting dist find the crate in the subdirectory. Two CI concerns stay separate: the existing bash/npm `ci.yml` + `release.yml` (changesets) are untouched except a filename rename to free `release.yml` for cargo-dist; a new `rust.yml` gates the crate, and a new dist-generated `release.yml` builds/publishes binaries on `v*` tags. + +**Tech Stack:** Rust 2024 edition (stable + pinned MSRV 1.85), `cargo fmt`/`clippy`, GitHub Actions, cargo-dist (`dist`), serde/serde_json (existing deps — unchanged). + +## Global Constraints + +- **Edition = 2024.** The crate is migrated from the 2021 edition to 2024 (current default for new crates). This is what *sets* the MSRV floor below. +- **MSRV floor = Rust 1.85** — Rust 2024 edition stabilized in 1.85, so the edition itself forces the floor (it subsumes the older `Option::is_none_or`/1.82 floor). Declared via Cargo's `rust-version` field and *proven* by a CI job that builds on exactly 1.85 (earned, not assumed — mirrors the npm CLI's Node-compat matrix philosophy in `AGENTS.md`). Note: this narrows the support floor vs. the "broad floor" instinct in `AGENTS.md`, but the edition bump is a deliberate, requested tradeoff. +- **Never crash the host.** `panic = "unwind"` (required for the top-level `catch_unwind`) must survive into cargo-dist's build profile. Any `[profile.dist]` must `inherits = "release"` and must NOT set `panic = "abort"`. +- **Bash + `install.sh` stays first-class.** Nothing in this plan forces any user onto the binary; the shell script remains the drop-in default. npm keeps shipping bash (Plan 3 wires npm→binary later). +- **Binary versioning is an independent `v*` semver track**, starting at `0.1.0`. Its git tags (`v0.1.0`, …) must never collide with changesets' npm tags (`@inbrace-tech/tokenline@x.y.z`). They don't overlap by construction; keep it that way. +- **Pin every third-party GitHub Action to a full commit SHA** with a trailing `# vX.Y.Z` comment — repo convention (see `.github/workflows/ci.yml`). `actions/checkout` is already pinned to `9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0` elsewhere; reuse that SHA. +- **Release targets (verbatim):** `x86_64-unknown-linux-musl`, `aarch64-unknown-linux-musl`, `aarch64-apple-darwin`, `x86_64-apple-darwin`, `x86_64-pc-windows-msvc`. +- **Installers:** `shell` (curl|sh) + `powershell` (irm|iex) only. Homebrew/msi/npm are later follow-ups. +- All work lands on branch `feat/rust-statusline` (base `main`). Do NOT push any `v*` tag or merge in this plan — the release pipeline is *wired and locally proven*, not fired. Cutting the first real release happens after the branch is merged (a separate, user-driven decision). + +--- + +### Task 1: Migrate to Rust 2024 edition + pin MSRV 1.85 + +Migrate the crate from the 2021 edition to 2024, then declare the MSRV floor the edition forces (Rust 1.85). `cargo fix --edition` auto-applies the 2024 breaking changes (e.g. wrapping the `extern` declaration of `libc_getuid` in `cache.rs` in an `unsafe extern` block). This runs **first** so the `cargo fmt` baseline (Task 2) captures the post-migration code. + +**Files:** +- Modify: `rust/Cargo.toml` (`edition = "2024"` + add `rust-version`) +- Modify: `rust/src/*.rs` (whatever `cargo fix --edition` rewrites — expected minimal) + +**Interfaces:** +- Consumes: nothing. +- Produces: `edition = "2024"` and `rust-version = "1.85"` in `[package]` (Task 3's `msrv` job pins to 1.85; Task 2's fmt baseline runs over the migrated code). + +- [ ] **Step 1: Confirm the crate is on edition 2021 and green (baseline)** + +Run: `grep '^edition' rust/Cargo.toml && cd rust && cargo test` +Expected: `edition = "2021"`; 35 tests pass. This is the pre-migration baseline. + +- [ ] **Step 2: Apply the automated edition migration** + +```bash +cd rust && cargo fix --edition --allow-dirty --allow-staged --lib --bins --tests +``` +Expected: exit 0. This rewrites any 2021→2024 incompatibilities in place (does NOT bump the edition field yet). + +- [ ] **Step 3: Bump the edition and add the MSRV field** + +In `rust/Cargo.toml` `[package]`, change `edition = "2021"` to `edition = "2024"`, and add after `license = "MIT"`: + +```toml +rust-version = "1.85" # MSRV floor forced by edition 2024 (stabilized in 1.85); proven by CI msrv job +``` + +- [ ] **Step 4: Verify the migrated crate is fully green** + +Run: `cd rust && cargo clippy --all-targets -- -D warnings && cargo test` +Expected: both exit 0 (clippy clean, 35 tests pass) on the 2024 edition. + +- [ ] **Step 5: Verify the floor is real — builds on 1.85, not below** + +```bash +rustup toolchain install 1.85.0 --profile minimal +rustup toolchain install 1.84.0 --profile minimal +cd rust && cargo +1.85.0 build # expect: success +cd rust && cargo +1.84.0 build 2>&1 | grep -i "edition\|2024\|1\.85" # expect: FAILS citing edition 2024 needs 1.85 +``` +Expected: 1.85.0 builds; 1.84.0 fails citing edition 2024 requires Rust 1.85. (If 1.84 unexpectedly builds, re-derive the true floor from the error before trusting the pin.) + +- [ ] **Step 6: Commit** + +```bash +git add rust/Cargo.toml rust/src +git commit -m "build(rust): migrate to Rust 2024 edition, pin MSRV 1.85 + +Edition 2024 (default for new crates); its 1.85 stabilization sets the +MSRV floor, subsuming the earlier is_none_or/1.82 floor." +``` + +--- + +### Task 2: Adopt canonical `cargo fmt` + enable the format gate baseline + +The crate uses a compact brace style; canonical `cargo fmt` produces a large one-time reformat. Adopt the canonical style now (standard Rust convention, no nightly, no bespoke `rustfmt.toml` to maintain) so Task 3's `cargo fmt --check` gate is green from its first run. Runs **after** the edition migration (Task 1) so it formats the final code once. + +**Files:** +- Modify: `rust/src/*.rs` (mechanical reformat only) +- Modify: `rust/tests/*.rs` (mechanical reformat only) + +**Interfaces:** +- Consumes: the edition-2024 code from Task 1. +- Produces: a repository state where `cargo fmt --check` exits 0 (Task 3's `fmt` job depends on this). + +- [ ] **Step 1: Confirm the gate currently fails** + +Run: `cd rust && cargo fmt --check` +Expected: non-zero exit with a large diff (compact style vs canonical). This is the "failing test" for this task. + +- [ ] **Step 2: Apply the canonical reformat** + +Run: `cd rust && cargo fmt` +Expected: exit 0, working tree now has reformatted `.rs` files. + +- [ ] **Step 3: Verify the gate now passes and nothing else broke** + +Run: `cd rust && cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test` +Expected: all three exit 0 (fmt clean, clippy clean, 35 tests pass). Formatting is non-semantic, so tests must still pass unchanged. + +- [ ] **Step 4: Commit** + +```bash +git add rust/src rust/tests +git commit -m "style(rust): adopt canonical cargo fmt formatting + +One-time reformat to the default rustfmt style so CI can gate on +\`cargo fmt --check\` without a nightly-only rustfmt.toml. No logic change." +``` + +--- + +### Task 3: Rust CI workflow — fmt + clippy + test matrix (Linux/macOS/Windows) + MSRV + fan-in gate + +Add `.github/workflows/rust.yml`. Mirrors the conventions in `ci.yml` (SHA-pinned actions, `concurrency` group, least-privilege `permissions`, `timeout-minutes`, and a single fan-in gate job whose fixed name is what branch protection requires). The Windows leg of the test matrix runs `cargo test`, which executes `never_crash.rs` (spawns the real built binary via stdin, asserts exit-0 + render) and `golden.rs` (value-diff against the committed golden) — that IS the "run the binary on Windows" done-criteria; no separate windows-run job is needed. + +**Files:** +- Create: `.github/workflows/rust.yml` + +**Interfaces:** +- Consumes: `rust-version = "1.85"` + edition 2024 (Task 1); `cargo fmt --check` clean (Task 2). +- Produces: a required status check named `Rust` (fan-in gate). The user must add `Rust` to the branch-protection ruleset after merge (noted in the final report — cannot be done from the branch). + +- [ ] **Step 1: Write the workflow** + +Create `.github/workflows/rust.yml`. Pin `dtolnay/rust-toolchain` to its current commit SHA before committing (Step 3); the `@master`/`@1.82.0` refs below are placeholders for the SHA-pin step, exactly as `ci.yml` pins its actions. + +```yaml +# ───────────────────────────────────────────────────────────────────────────── +# Rust CI — gates the tokenline binary crate in rust/. Runs on every PR/push. +# fmt + clippy + test across Linux/macOS/Windows, plus an MSRV build on the +# declared floor (1.82). The Windows test leg runs the actual binary via stdin +# (tests/never_crash.rs) — that is the "runs on Windows" done-criteria. +# ───────────────────────────────────────────────────────────────────────────── +name: Rust + +on: + pull_request: + branches: [main] + push: + branches: [main] + +# Cancel stale PR runs; never cancel push-to-main runs. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +defaults: + run: + working-directory: rust + +jobs: + fmt: + name: rustfmt + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: dtolnay/rust-toolchain@stable # PIN TO SHA + with: + components: rustfmt + - run: cargo fmt --check + + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: dtolnay/rust-toolchain@stable # PIN TO SHA + with: + components: clippy + - name: Clippy (deny warnings) + run: cargo clippy --all-targets -- -D warnings + - name: Test (runs the built binary via stdin on this OS) + run: cargo test + + msrv: + name: MSRV (1.85) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: dtolnay/rust-toolchain@1.85.0 # PIN TO SHA + - name: Build on the declared MSRV floor + run: cargo build --bins --lib + + # Thin fan-in gate. This exact string "Rust" is the required status check + # in branch protection, so it must stay a single fixed name that fans in over + # fmt + test + msrv. `if: always()` + explicit result checks make it a real + # pass/fail instead of a "skipped" that branch protection treats as green. + rust: + name: Rust + needs: [fmt, test, msrv] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Gate on the Rust jobs + working-directory: ${{ github.workspace }} + run: | + if [ "${{ needs.fmt.result }}" != "success" ] \ + || [ "${{ needs.test.result }}" != "success" ] \ + || [ "${{ needs.msrv.result }}" != "success" ]; then + echo "::error::Rust fmt, test matrix, or MSRV build did not pass." + exit 1 + fi +``` + +- [ ] **Step 2: Validate the workflow YAML locally** + +Run: `python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/rust.yml')); print('yaml ok')"` +Expected: `yaml ok` (no parse error). + +- [ ] **Step 3: Pin the Rust toolchain action to a commit SHA** + +Resolve the current commit SHA for the `dtolnay/rust-toolchain` refs and replace each `@stable`/`@1.82.0` with `@ # ` (repo convention). Example: + +```bash +gh api repos/dtolnay/rust-toolchain/commits/master --jq '.sha' # -> use for @stable legs +gh api repos/dtolnay/rust-toolchain/commits/1.85.0 --jq '.sha' # -> use for the msrv leg +``` +Edit `rust.yml` so no third-party action is referenced by a mutable tag/branch. Re-run Step 2 to confirm the YAML still parses. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/rust.yml +git commit -m "ci(rust): add fmt + clippy + test matrix + MSRV gate" +``` + +--- + +### Task 4: cargo-dist config + release pipeline + +Wire cargo-dist from a repo-root `dist-workspace.toml` pointing at the `rust/` crate. This generates `.github/workflows/release.yml` (binary builds + `curl|sh`/`irm|iex` installers + GitHub Release assets on `v*` tags). First free the `release.yml` filename by renaming the existing npm changesets workflow. + +**Files:** +- Rename: `.github/workflows/release.yml` → `.github/workflows/release-npm.yml` (content unchanged except `name:`) +- Create: `dist-workspace.toml` (repo root) +- Create: `.github/workflows/release.yml` (generated by `dist init`) +- Modify: `rust/Cargo.toml` (dist adds `[profile.dist]`) + +**Interfaces:** +- Consumes: `rust/Cargo.toml` with `rust-version` (Task 2) and `[profile.release] panic = "unwind"`. +- Produces: `dist-workspace.toml` with the locked targets/installers; a `release.yml` triggered on `v*` tags. + +- [ ] **Step 1: Rename the npm release workflow to free `release.yml`** + +```bash +git mv .github/workflows/release.yml .github/workflows/release-npm.yml +``` +Then edit `.github/workflows/release-npm.yml` and change the workflow name line from `name: Release` to `name: Release (npm)` so the two release workflows are distinguishable in the Actions UI. Leave triggers (push to main) and all steps unchanged — changesets does not depend on the filename. + +- [ ] **Step 2: Install cargo-dist (`dist`)** + +```bash +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/latest/download/cargo-dist-installer.sh | sh +dist --version # record this exact version — it is written into the config for reproducibility +``` +Expected: `dist` on PATH, prints a version (e.g. `dist 0.x.y`). + +- [ ] **Step 3: Ensure `rust/Cargo.toml` has a repository URL (dist requires it)** + +dist blocks on knowing where the project is hosted. Confirm `rust/Cargo.toml`'s `[package]` has a `repository` key; if absent, add: + +```toml +repository = "https://github.com/inbrace-tech/tokenline" +``` +Run: `grep -q '^repository' rust/Cargo.toml && echo present || echo MISSING` +Expected: `present`. + +- [ ] **Step 4: Run `dist init` and reconcile to the target config** + +```bash +dist init --yes +``` +`dist init` creates `dist-workspace.toml` (or writes `[workspace.metadata.dist]`), adds `[profile.dist]` to `rust/Cargo.toml`, and generates `.github/workflows/release.yml`. Then edit `dist-workspace.toml` so it matches this exact target (reconcile whatever `dist init` produced to this — replace `cargo-dist-version` with the version printed in Step 2): + +```toml +[workspace] +members = ["cargo:rust"] + +[dist] +# Recorded by `dist init`; use the version from Step 2 verbatim. +cargo-dist-version = "0.X.Y" +# curl|sh + irm|iex installers (Homebrew/npm/msi are later follow-ups). +installers = ["shell", "powershell"] +# Locked release targets. +targets = [ + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-musl", + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-pc-windows-msvc", +] +# CI backend that builds + publishes the above. +ci = "github" +``` + +- [ ] **Step 5: Verify `panic = "unwind"` survives into the dist profile** + +`dist init` adds `[profile.dist]` to `rust/Cargo.toml`. Confirm it inherits `release` (which sets `panic = "unwind"`) and does NOT itself set `panic = "abort"` — abort would make a panic a non-zero exit and break the never-crash guarantee. + +```bash +grep -A4 '\[profile.dist\]' rust/Cargo.toml +grep -q 'panic *= *"abort"' rust/Cargo.toml && echo "FAIL: abort present" || echo "ok: no abort" +``` +Expected: `[profile.dist]` shows `inherits = "release"`; second command prints `ok: no abort`. If `dist init` added `panic = "abort"` anywhere, remove it. + +- [ ] **Step 6: Validate the release plan (the pipeline "test" — no build, no publish)** + +```bash +dist plan +``` +Expected: exit 0; prints a plan listing all 5 targets and the shell + powershell installers, with no config errors. This proves the config is internally consistent without building or tagging anything. + +- [ ] **Step 7: Confirm the generated release workflow triggers on tags, not push-to-main** + +```bash +grep -A6 '^on:' .github/workflows/release.yml +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/release.yml')); print('yaml ok')" +``` +Expected: the trigger is a tag pattern (e.g. `push: tags: ['**[0-9]+.[0-9]+.[0-9]+*']`), NOT `branches: [main]`; YAML parses. This guarantees it stays dormant until a `v*` tag is pushed (which this plan does not do) and never races the npm `release-npm.yml`. + +- [ ] **Step 8: Pin third-party actions in the generated workflow (best-effort) and commit** + +dist-generated workflows usually pin actions already; if any are on mutable tags, pin them to SHAs per repo convention (`ci.yml` style). Then commit: + +```bash +git add dist-workspace.toml rust/Cargo.toml \ + .github/workflows/release.yml .github/workflows/release-npm.yml +git commit -m "build(rust): wire cargo-dist release pipeline + +dist-workspace.toml drives cargo-dist from the repo root at the rust/ +crate. Generates release.yml (binary build matrix + shell/powershell +installers + GitHub Release assets) on v* tags. Renames the npm +changesets workflow to release-npm.yml to free the release.yml name; +their tag/branch triggers do not overlap." +``` + +--- + +### Task 5: Prove the pipeline builds real artifacts locally + document the release process + +`dist plan` (Task 4) validates config but builds nothing. Since this plan does not push a tag, prove the pipeline actually produces a binary + installer for the host platform with a local build, and document how a maintainer cuts the first release after merge. + +**Files:** +- Create: `rust/RELEASING.md` (release runbook for the binary track) + +**Interfaces:** +- Consumes: the `dist-workspace.toml` from Task 4. +- Produces: developer-facing docs; no code. + +- [ ] **Step 1: Build host-platform artifacts locally (the end-to-end proof)** + +```bash +dist build --artifacts=local +``` +Expected: exit 0; produces an archive for the host target and the installer script(s) under `target/distrib/` (or the path dist prints). This exercises the real build + packaging path without needing all 5 cross-toolchains or a tag. + +- [ ] **Step 2: Sanity-check the built binary still never-crashes** + +Locate the built binary dist just produced and confirm the never-crash contract holds on a real release-profile build: + +```bash +echo 'not json' | ./target/dist/tokenline; echo "exit=$?" +``` +Expected: empty output, `exit=0`. (Adjust the path to wherever `dist build` reports the binary; the profile is `dist`, which inherits `release` → `panic = "unwind"`.) + +- [ ] **Step 3: Write the release runbook** + +Create `rust/RELEASING.md`: + +```markdown +# Releasing the `tokenline` binary + +The Rust binary ships on its own `v*` semver track via cargo-dist, independent +of the npm package (which versions via changesets and tags `@inbrace-tech/tokenline@x.y.z`). + +## Cutting a release + +1. Bump `version` in `rust/Cargo.toml` (starts at `0.1.0`; earns `1.0.0` at + full parity + Windows-verified). +2. Commit on `main`. +3. Tag and push: + ```bash + git tag v0.1.0 + git push origin v0.1.0 + ``` +4. `.github/workflows/release.yml` (cargo-dist) fans out the build matrix for all + five targets, generates the `curl|sh` + `irm|iex` installers, and attaches the + archives + installers to a GitHub Release. + +## Targets + +`x86_64`/`aarch64-unknown-linux-musl` (static), `aarch64`/`x86_64-apple-darwin`, +`x86_64-pc-windows-msvc`. + +## Install (once a release exists) + +- Unix: `curl --proto '=https' --tlsv1.2 -LsSf https://github.com/inbrace-tech/tokenline/releases/latest/download/tokenline-installer.sh | sh` +- Windows: `irm https://github.com/inbrace-tech/tokenline/releases/latest/download/tokenline-installer.ps1 | iex` + +## Notes + +- Never set `panic = "abort"` in `[profile.dist]` — the never-crash guarantee + relies on `catch_unwind`, which needs `panic = "unwind"`. +- The bash `install.sh` path stays first-class; the binary is an additional + channel, not a replacement. +``` + +- [ ] **Step 4: Commit** + +```bash +git add rust/RELEASING.md +git commit -m "docs(rust): add binary release runbook" +``` + +--- + +## Post-plan manual follow-ups (cannot be done from the branch) + +These are recorded for the final report / the user — they require repo-admin access or a merge, so they are explicitly out of the task scope: + +1. **Add `Rust` to the branch-protection required status checks** (alongside the existing `CLI`) so the new gate is enforced on PRs. +2. **First real release** is deferred until after the branch is merged — tag `v0.1.0` then (Task 5 runbook). This plan wires and locally proves the pipeline but pushes no tag. +3. **Homebrew / npm-delivers-binary** are Plan 3 (`optionalDependencies` per platform; `doctor` drops the jq check). + +## Self-review notes + +- **Spec coverage:** Rust 2024 edition migration (Task 1) ✓; cargo-dist pipeline (Task 4) ✓; CI matrix running the binary on windows-latest + linux/macos (Task 3, via `cargo test` → `never_crash.rs`) ✓; MSRV decided + wired (Task 1 pin + Task 3 job) ✓; `cargo fmt` policy decided + wired (Task 2 + Task 3 `fmt` job) ✓; `curl|sh` + `.ps1` installers (Task 4 `installers = ["shell","powershell"]`) ✓. +- **Type/name consistency:** the fan-in gate job id `rust` / display name `Rust`; MSRV `1.85` consistent across Task 1 (`rust-version` + edition 2024) and Task 3 (`dtolnay/rust-toolchain@1.85.0`); the five targets are identical everywhere. +- **Deliberate simplifications (ponytail):** no build caching (`Swatinem/rust-cache`) — the crate is tiny and builds fast; add if CI wall-time becomes a problem. No `rustfmt.toml` — canonical style needs no config. No separate windows-run job — the windows test leg already runs the real binary via stdin. From 921d00f7e112794d1f04714b90270b88e4f24684 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 11:19:33 -0300 Subject: [PATCH 16/23] build(rust): migrate to Rust 2024 edition, pin MSRV 1.85 Edition 2024 (default for new crates); its 1.85 stabilization sets the MSRV floor, subsuming the earlier is_none_or/1.82 floor. --- rust/Cargo.toml | 3 ++- rust/src/cache.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 176c313..61f1b54 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "tokenline" version = "0.1.0" -edition = "2021" +edition = "2024" description = "A cache-aware statusline for AI coding CLIs" license = "MIT" +rust-version = "1.85" # MSRV floor forced by edition 2024 (stabilized in 1.85); proven by CI msrv job [[bin]] name = "tokenline" diff --git a/rust/src/cache.rs b/rust/src/cache.rs index c103226..b419e09 100644 --- a/rust/src/cache.rs +++ b/rust/src/cache.rs @@ -38,7 +38,7 @@ fn unix_uid() -> String { (unsafe { libc_getuid() }).to_string() } #[cfg(unix)] -extern "C" { #[link_name = "getuid"] fn libc_getuid() -> u32; } +unsafe extern "C" { #[link_name = "getuid"] fn libc_getuid() -> u32; } #[cfg(windows)] fn platform_base() -> PathBuf { From b8612d11895d4bb7ac7f1f91a5e9d061ff1ee8ce Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 11:22:05 -0300 Subject: [PATCH 17/23] style(rust): adopt canonical cargo fmt formatting One-time reformat to the default rustfmt style so CI can gate on `cargo fmt --check` without a nightly-only rustfmt.toml. No logic change. --- rust/src/cache.rs | 53 +++++++++++++---- rust/src/cachetimer.rs | 109 ++++++++++++++++++++++++++-------- rust/src/economics.rs | 76 +++++++++++++++++------- rust/src/epoch.rs | 41 ++++++++++--- rust/src/fmt.rs | 67 ++++++++++++++------- rust/src/input.rs | 72 +++++++++++++++------- rust/src/lib.rs | 2 +- rust/src/main.rs | 12 ++-- rust/src/ratelimits.rs | 71 ++++++++++++++++------ rust/src/render.rs | 122 ++++++++++++++++++++++++++++---------- rust/tests/golden.rs | 65 ++++++++++++++++---- rust/tests/never_crash.rs | 25 ++++++-- 12 files changed, 536 insertions(+), 179 deletions(-) diff --git a/rust/src/cache.rs b/rust/src/cache.rs index b419e09..2e45e2e 100644 --- a/rust/src/cache.rs +++ b/rust/src/cache.rs @@ -9,7 +9,12 @@ pub struct CacheState { impl Default for CacheState { fn default() -> Self { - CacheState { last_ts: None, ttl: 300, ttl_label: "5m".into(), last_tokens: 0 } + CacheState { + last_ts: None, + ttl: 300, + ttl_label: "5m".into(), + last_tokens: 0, + } } } @@ -21,7 +26,11 @@ pub fn runtime_dir() -> PathBuf { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700)); } - if base.is_dir() { base } else { std::env::temp_dir() } + if base.is_dir() { + base + } else { + std::env::temp_dir() + } } #[cfg(unix)] @@ -38,7 +47,10 @@ fn unix_uid() -> String { (unsafe { libc_getuid() }).to_string() } #[cfg(unix)] -unsafe extern "C" { #[link_name = "getuid"] fn libc_getuid() -> u32; } +unsafe extern "C" { + #[link_name = "getuid"] + fn libc_getuid() -> u32; +} #[cfg(windows)] fn platform_base() -> PathBuf { @@ -49,26 +61,41 @@ fn platform_base() -> PathBuf { } fn file_for(dir: &Path, session_id: &str) -> PathBuf { - let id = if session_id.is_empty() { "default" } else { session_id }; + let id = if session_id.is_empty() { + "default" + } else { + session_id + }; dir.join(format!("session-{}", id)) } pub fn load(dir: &Path, session_id: &str) -> CacheState { let mut st = CacheState::default(); - let Ok(raw) = std::fs::read_to_string(file_for(dir, session_id)) else { return st; }; + let Ok(raw) = std::fs::read_to_string(file_for(dir, session_id)) else { + return st; + }; let parts: Vec<&str> = raw.split_whitespace().collect(); if parts.len() >= 4 { st.last_ts = parts[0].parse::().ok(); - if let Ok(t) = parts[1].parse::() { st.ttl = t; } + if let Ok(t) = parts[1].parse::() { + st.ttl = t; + } st.ttl_label = parts[2].to_string(); - if let Ok(tk) = parts[3].parse::() { st.last_tokens = tk; } + if let Ok(tk) = parts[3].parse::() { + st.last_tokens = tk; + } } st } pub fn store(dir: &Path, session_id: &str, st: &CacheState) { - let line = format!("{} {} {} {}", - st.last_ts.unwrap_or(0), st.ttl, st.ttl_label, st.last_tokens); + let line = format!( + "{} {} {} {}", + st.last_ts.unwrap_or(0), + st.ttl, + st.ttl_label, + st.last_tokens + ); let _ = std::fs::write(file_for(dir, session_id), line); // best-effort } @@ -87,8 +114,12 @@ mod tests { #[test] fn roundtrip() { let dir = scratch(); - let st = CacheState { last_ts: Some(1_783_274_400), ttl: 3600, - ttl_label: "1h".into(), last_tokens: 88_802 }; + let st = CacheState { + last_ts: Some(1_783_274_400), + ttl: 3600, + ttl_label: "1h".into(), + last_tokens: 88_802, + }; store(&dir, "sess1", &st); let got = load(&dir, "sess1"); assert_eq!(got.last_ts, Some(1_783_274_400)); diff --git a/rust/src/cachetimer.rs b/rust/src/cachetimer.rs index f4ebd3b..4ad67ee 100644 --- a/rust/src/cachetimer.rs +++ b/rust/src/cachetimer.rs @@ -1,10 +1,14 @@ -use std::path::Path; use crate::cache; use crate::epoch::iso8601_to_epoch; use crate::input::Session; +use std::path::Path; #[derive(Debug)] -pub enum Warmth { Warm, Cooling, Cold } +pub enum Warmth { + Warm, + Cooling, + Cold, +} pub struct CacheInfo { pub ttl_label: String, @@ -16,17 +20,31 @@ pub struct CacheInfo { /// Read the last assistant/PLANNER turn's (timestamp, ephemeral_5m, ephemeral_1h) /// from the tail of the transcript. Returns (last_ts, e5m, e1h). fn scan_transcript(path: &str) -> (Option, u64, u64) { - let Ok(content) = std::fs::read_to_string(path) else { return (None, 0, 0); }; + let Ok(content) = std::fs::read_to_string(path) else { + return (None, 0, 0); + }; // Tail ~200 lines (tokenline.sh:199). Scan from the end for the last match. for line in content.lines().rev().take(200) { - let Ok(v) = serde_json::from_str::(line) else { continue; }; + let Ok(v) = serde_json::from_str::(line) else { + continue; + }; let t = v.get("type").and_then(|t| t.as_str()).unwrap_or(""); - if t != "assistant" && t != "PLANNER_RESPONSE" { continue; } - let iso = v.get("timestamp").and_then(|x| x.as_str()) + if t != "assistant" && t != "PLANNER_RESPONSE" { + continue; + } + let iso = v + .get("timestamp") + .and_then(|x| x.as_str()) .or_else(|| v.get("created_at").and_then(|x| x.as_str())); let cc = v.pointer("/message/usage/cache_creation"); - let e5m = cc.and_then(|c| c.get("ephemeral_5m_input_tokens")).and_then(|x| x.as_u64()).unwrap_or(0); - let e1h = cc.and_then(|c| c.get("ephemeral_1h_input_tokens")).and_then(|x| x.as_u64()).unwrap_or(0); + let e5m = cc + .and_then(|c| c.get("ephemeral_5m_input_tokens")) + .and_then(|x| x.as_u64()) + .unwrap_or(0); + let e1h = cc + .and_then(|c| c.get("ephemeral_1h_input_tokens")) + .and_then(|x| x.as_u64()) + .unwrap_or(0); return (iso.and_then(iso8601_to_epoch), e5m, e1h); } (None, 0, 0) @@ -53,11 +71,15 @@ pub fn cache_info(s: &Session, now: i64, dir: &Path) -> CacheInfo { let (ts, a, b) = scan_transcript(&s.transcript_path); let ts = ts.or_else(|| file_mtime(&s.transcript_path)); // mtime fallback (ts, a, b) - } else { (None, 0, 0) }; + } else { + (None, 0, 0) + }; // Prefer cached ts if newer or transcript ts missing (tokenline.sh:216-223) if let Some(cached) = state.last_ts { - if last_ts.is_none_or(|t| cached > t) { last_ts = Some(cached); } + if last_ts.is_none_or(|t| cached > t) { + last_ts = Some(cached); + } } // Always have a timestamp (tokenline.sh:225-229) let last_ts = last_ts.unwrap_or(now); @@ -82,11 +104,22 @@ pub fn cache_info(s: &Session, now: i64, dir: &Path) -> CacheInfo { let remaining = ttl - elapsed; // pct10 == remaining*10/ttl (tokenline.sh:260) let pct10 = if ttl > 0 { remaining * 10 / ttl } else { 0 }; - let warmth = if remaining <= 0 { Warmth::Cold } - else if pct10 < 2 { Warmth::Cooling } // < 20% left - else { Warmth::Warm }; + let warmth = if remaining <= 0 { + Warmth::Cold + } else if pct10 < 2 { + Warmth::Cooling + } + // < 20% left + else { + Warmth::Warm + }; - CacheInfo { ttl_label: ttl_label.to_string(), remaining_secs: remaining, warmth, is_1h: ttl == 3600 } + CacheInfo { + ttl_label: ttl_label.to_string(), + remaining_secs: remaining, + warmth, + is_1h: ttl == 3600, + } } #[cfg(test)] @@ -100,17 +133,21 @@ mod tests { std::fs::create_dir_all(&d).unwrap(); d } - fn sess(json: &str) -> Session { Session::from_raw(serde_json::from_str(json).unwrap()) } + fn sess(json: &str) -> Session { + Session::from_raw(serde_json::from_str(json).unwrap()) + } #[test] fn fresh_session_starts_warm_at_full_ttl() { let dir = scratch(); // no transcript, no cache => last_ts defaults to now => remaining == ttl - let s = sess(r#"{"session_id":"warm1","context_window":{"current_usage": - {"cache_read_input_tokens":100}}}"#); + let s = sess( + r#"{"session_id":"warm1","context_window":{"current_usage": + {"cache_read_input_tokens":100}}}"#, + ); let now = 1_783_000_000; let ci = cache_info(&s, now, &dir); - assert_eq!(ci.ttl_label, "5m"); // no ephemeral fields => default 5m + assert_eq!(ci.ttl_label, "5m"); // no ephemeral fields => default 5m assert!(ci.remaining_secs > 290 && ci.remaining_secs <= 300); assert!(matches!(ci.warmth, Warmth::Warm)); } @@ -129,16 +166,26 @@ mod tests { )).unwrap(); let turn_epoch = crate::epoch::iso8601_to_epoch("2026-07-05T00:00:00Z").unwrap(); let now = turn_epoch + 100; // 100s after the turn - let s = sess(&format!(r#"{{"session_id":"h1","transcript_path":"{}", + let s = sess(&format!( + r#"{{"session_id":"h1","transcript_path":"{}", "context_window":{{"current_usage":{{"cache_read_input_tokens":100}}}}}}"#, - tp.display())); + tp.display() + )); // Prime last_tokens to match s.tokens_used so turn detection does NOT fire this // poll (tokenline.sh:185 fires on a *change*; a truly first-ever poll would stamp // `now` and clobber the transcript-derived ts, which is not what this test probes). // This models steady-state polling: no new turn since last render, so the last // real turn time must come from the transcript, not from "now". - crate::cache::store(&dir, "h1", &crate::cache::CacheState { - last_ts: None, ttl: 300, ttl_label: "5m".into(), last_tokens: 100 }); + crate::cache::store( + &dir, + "h1", + &crate::cache::CacheState { + last_ts: None, + ttl: 300, + ttl_label: "5m".into(), + last_tokens: 100, + }, + ); let ci = cache_info(&s, now, &dir); assert_eq!(ci.ttl_label, "1h"); assert!(ci.is_1h); @@ -149,11 +196,21 @@ mod tests { #[test] fn expired_is_cold() { let dir = scratch(); - let s = sess(r#"{"session_id":"cold1","context_window":{"current_usage": - {"cache_read_input_tokens":100}}}"#); + let s = sess( + r#"{"session_id":"cold1","context_window":{"current_usage": + {"cache_read_input_tokens":100}}}"#, + ); // Prime the cache with an old timestamp + 5m ttl - crate::cache::store(&dir, "cold1", &crate::cache::CacheState{ - last_ts: Some(1_000_000), ttl: 300, ttl_label: "5m".into(), last_tokens: 100 }); + crate::cache::store( + &dir, + "cold1", + &crate::cache::CacheState { + last_ts: Some(1_000_000), + ttl: 300, + ttl_label: "5m".into(), + last_tokens: 100, + }, + ); let now = 1_000_000 + 400; // 400s later, ttl 300 => expired let ci = cache_info(&s, now, &dir); assert!(ci.remaining_secs <= 0); diff --git a/rust/src/economics.rs b/rust/src/economics.rs index e9fa7f7..bc2a5c7 100644 --- a/rust/src/economics.rs +++ b/rust/src/economics.rs @@ -1,7 +1,11 @@ use crate::fmt::Role; use crate::input::Session; -pub struct Tier { pub role: Role, pub tokens: u64, pub eq_contrib: f64 } +pub struct Tier { + pub role: Role, + pub tokens: u64, + pub eq_contrib: f64, +} pub struct Economics { pub eq: u64, @@ -18,32 +22,54 @@ pub fn economics(s: &Session, cache_is_1h: bool) -> Economics { (0.1_f64, if cache_is_1h { 2.0 } else { 1.25 }, 1.0, 5.0) }; - let read_eq = s.cur_cread as f64 * read_m; + let read_eq = s.cur_cread as f64 * read_m; let write_eq = s.cur_cwrite as f64 * write_m; - let new_eq = s.cur_input as f64 * new_m; - let out_eq = s.cur_output as f64 * out_m; + let new_eq = s.cur_input as f64 * new_m; + let out_eq = s.cur_output as f64 * out_m; // Match bash awk `printf "%d"` (truncation toward zero). let eq = (read_eq + write_eq + new_eq + out_eq) as u64; - let uncached_eq = - ((s.cur_cread + s.cur_cwrite + s.cur_input) as f64 * new_m + out_eq) as u64; + let uncached_eq = ((s.cur_cread + s.cur_cwrite + s.cur_input) as f64 * new_m + out_eq) as u64; let saving_pct = if uncached_eq > 0 { (100.0 * (uncached_eq as f64 - eq as f64) / uncached_eq as f64) as i64 - } else { 0 }; + } else { + 0 + }; // Thermometer order: cheap -> dear (read, new, write, output). let tiers = [ - Tier { role: Role::Read, tokens: s.cur_cread, eq_contrib: read_eq }, - Tier { role: Role::New, tokens: s.cur_input, eq_contrib: new_eq }, - Tier { role: Role::Write, tokens: s.cur_cwrite, eq_contrib: write_eq }, - Tier { role: Role::Output, tokens: s.cur_output, eq_contrib: out_eq }, + Tier { + role: Role::Read, + tokens: s.cur_cread, + eq_contrib: read_eq, + }, + Tier { + role: Role::New, + tokens: s.cur_input, + eq_contrib: new_eq, + }, + Tier { + role: Role::Write, + tokens: s.cur_cwrite, + eq_contrib: write_eq, + }, + Tier { + role: Role::Output, + tokens: s.cur_output, + eq_contrib: out_eq, + }, ]; - let any_activity = - s.cur_cread > 0 || s.cur_cwrite > 0 || s.cur_input > 0 || s.cur_output > 0; + let any_activity = s.cur_cread > 0 || s.cur_cwrite > 0 || s.cur_input > 0 || s.cur_output > 0; - Economics { eq, uncached_eq, saving_pct, tiers, any_activity } + Economics { + eq, + uncached_eq, + saving_pct, + tiers, + any_activity, + } } #[cfg(test)] @@ -51,7 +77,9 @@ mod tests { use super::*; use crate::input::Session; - fn sess(json: &str) -> Session { Session::from_raw(serde_json::from_str(json).unwrap()) } + fn sess(json: &str) -> Session { + Session::from_raw(serde_json::from_str(json).unwrap()) + } #[test] fn anthropic_1h_matches_bash() { @@ -59,9 +87,11 @@ mod tests { // eq = 86600*0.1 + 2200*2 + 2*1 + 2000*5 = 8660 + 4400 + 2 + 10000 = 23062 // unc = (86600+2200+2)*1 + 2000*5 = 88802 + 10000 = 98802 // save = floor(100*(98802-23062)/98802) = 76 - let s = sess(r#"{"model":{"display_name":"Opus 4.8"},"context_window":{"current_usage": + let s = sess( + r#"{"model":{"display_name":"Opus 4.8"},"context_window":{"current_usage": {"input_tokens":2,"output_tokens":2000,"cache_creation_input_tokens":2200, - "cache_read_input_tokens":86600}}}"#); + "cache_read_input_tokens":86600}}}"#, + ); let e = economics(&s, true); assert_eq!(e.eq, 23_062); assert_eq!(e.uncached_eq, 98_802); @@ -73,9 +103,11 @@ mod tests { #[test] fn anthropic_5m_uses_1_25_write() { // write mult 1.25 when cache window is 5m - let s = sess(r#"{"context_window":{"current_usage": + let s = sess( + r#"{"context_window":{"current_usage": {"cache_creation_input_tokens":1000,"output_tokens":0, - "input_tokens":0,"cache_read_input_tokens":0}}}"#); + "input_tokens":0,"cache_read_input_tokens":0}}}"#, + ); let e = economics(&s, false); assert_eq!(e.eq, 1250); // 1000 * 1.25 } @@ -83,9 +115,11 @@ mod tests { #[test] fn gemini_multipliers() { // read 0.25, write 1.0, new 1, output 4 - let s = sess(r#"{"model":{"display_name":"Gemini 2.5 Pro"},"context_window":{"current_usage": + let s = sess( + r#"{"model":{"display_name":"Gemini 2.5 Pro"},"context_window":{"current_usage": {"cache_read_input_tokens":1000,"cache_creation_input_tokens":1000, - "input_tokens":1000,"output_tokens":1000}}}"#); + "input_tokens":1000,"output_tokens":1000}}}"#, + ); let e = economics(&s, false); // eq = 250 + 1000 + 1000 + 4000 = 6250 assert_eq!(e.eq, 6250); diff --git a/rust/src/epoch.rs b/rust/src/epoch.rs index 81f4033..b609a88 100644 --- a/rust/src/epoch.rs +++ b/rust/src/epoch.rs @@ -3,7 +3,7 @@ fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { let y = if m <= 2 { y - 1 } else { y }; let era = if y >= 0 { y } else { y - 399 } / 400; - let yoe = y - era * 400; // [0, 399] + let yoe = y - era * 400; // [0, 399] let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; // [0, 365] let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096] era * 146_097 + doe - 719_468 @@ -12,21 +12,35 @@ fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { /// Parse `YYYY-MM-DDTHH:MM:SS` (first 19 chars, UTC) -> epoch seconds. pub fn iso8601_to_epoch(iso: &str) -> Option { let b = iso.as_bytes(); - if b.len() < 19 { return None; } + if b.len() < 19 { + return None; + } let num = |lo: usize, hi: usize| -> Option { std::str::from_utf8(&b[lo..hi]).ok()?.parse::().ok() }; // Positions: 0123-56-89 T 11:14:17 - if b[4] != b'-' || b[7] != b'-' || (b[10] != b'T' && b[10] != b' ') - || b[13] != b':' || b[16] != b':' { return None; } + if b[4] != b'-' + || b[7] != b'-' + || (b[10] != b'T' && b[10] != b' ') + || b[13] != b':' + || b[16] != b':' + { + return None; + } let year = num(0, 4)?; let month = num(5, 7)?; let day = num(8, 10)?; let hour = num(11, 13)?; let min = num(14, 16)?; let sec = num(17, 19)?; - if !(1..=12).contains(&month) || !(1..=31).contains(&day) - || !(0..=23).contains(&hour) || !(0..=59).contains(&min) || !(0..=60).contains(&sec) { return None; } + if !(1..=12).contains(&month) + || !(1..=31).contains(&day) + || !(0..=23).contains(&hour) + || !(0..=59).contains(&min) + || !(0..=60).contains(&sec) + { + return None; + } let days = days_from_civil(year, month, day); Some(days * 86_400 + hour * 3600 + min * 60 + sec) } @@ -41,16 +55,25 @@ mod tests { #[test] fn epoch_known_instant() { // 2026-07-05T18:00:00Z == 1783274400 - assert_eq!(iso8601_to_epoch("2026-07-05T18:00:00Z"), Some(1_783_274_400)); + assert_eq!( + iso8601_to_epoch("2026-07-05T18:00:00Z"), + Some(1_783_274_400) + ); } #[test] fn epoch_ignores_fractional_and_offset_suffix() { - assert_eq!(iso8601_to_epoch("2026-07-05T18:00:00.123456Z"), Some(1_783_274_400)); + assert_eq!( + iso8601_to_epoch("2026-07-05T18:00:00.123456Z"), + Some(1_783_274_400) + ); } #[test] fn epoch_leap_day() { // 2024-02-29T00:00:00Z == 1709164800 - assert_eq!(iso8601_to_epoch("2024-02-29T00:00:00Z"), Some(1_709_164_800)); + assert_eq!( + iso8601_to_epoch("2024-02-29T00:00:00Z"), + Some(1_709_164_800) + ); } #[test] fn epoch_rejects_garbage() { diff --git a/rust/src/fmt.rs b/rust/src/fmt.rs index 09a10e5..2097f8d 100644 --- a/rust/src/fmt.rs +++ b/rust/src/fmt.rs @@ -1,34 +1,49 @@ #[derive(Clone, Copy)] pub enum Role { - Ink, Muted, Faint, Good, Warn, Caution, Critical, Cache, Accent, - Read, New, Write, Output, + Ink, + Muted, + Faint, + Good, + Warn, + Caution, + Critical, + Cache, + Accent, + Read, + New, + Write, + Output, } impl Role { // PULSE+ refined truecolor palette (24-bit). fn rgb(self) -> (u8, u8, u8) { match self { - Role::Ink => (0xe6, 0xed, 0xf3), - Role::Muted => (0x8b, 0x94, 0x9e), - Role::Faint => (0x5b, 0x66, 0x73), - Role::Good => (0x3f, 0xb9, 0x50), - Role::Warn => (0xd2, 0x99, 0x22), - Role::Caution => (0xdb, 0x6d, 0x28), + Role::Ink => (0xe6, 0xed, 0xf3), + Role::Muted => (0x8b, 0x94, 0x9e), + Role::Faint => (0x5b, 0x66, 0x73), + Role::Good => (0x3f, 0xb9, 0x50), + Role::Warn => (0xd2, 0x99, 0x22), + Role::Caution => (0xdb, 0x6d, 0x28), Role::Critical => (0xf0, 0x50, 0x3c), - Role::Cache => (0x3f, 0xb9, 0x50), - Role::Accent => (0x58, 0xa6, 0xff), - Role::Read => (0x45, 0xc4, 0xa8), - Role::New => (0x6c, 0x9e, 0xff), - Role::Write => (0xe0, 0xa4, 0x58), - Role::Output => (0xd1, 0x8a, 0xc9), + Role::Cache => (0x3f, 0xb9, 0x50), + Role::Accent => (0x58, 0xa6, 0xff), + Role::Read => (0x45, 0xc4, 0xa8), + Role::New => (0x6c, 0x9e, 0xff), + Role::Write => (0xe0, 0xa4, 0x58), + Role::Output => (0xd1, 0x8a, 0xc9), } } pub fn paint(self, text: &str, bold: bool, blink: bool) -> String { let (r, g, b) = self.rgb(); let mut s = String::new(); s.push_str(&format!("\x1b[38;2;{};{};{}m", r, g, b)); - if bold { s.push_str("\x1b[1m"); } - if blink { s.push_str("\x1b[5m"); } + if bold { + s.push_str("\x1b[1m"); + } + if blink { + s.push_str("\x1b[5m"); + } s.push_str(text); s.push_str("\x1b[0m"); s @@ -49,14 +64,26 @@ pub fn fmt_k(v: u64) -> String { /// Mirrors tokenline.sh fmt_eta. pub fn fmt_eta(secs: i64) -> String { - if secs <= 0 { return "now".to_string(); } - if secs < 3600 { return format!("{}m", secs / 60); } + if secs <= 0 { + return "now".to_string(); + } + if secs < 3600 { + return format!("{}m", secs / 60); + } if secs < 86_400 { let (h, m) = (secs / 3600, (secs % 3600) / 60); - return if m > 0 { format!("{}h{}m", h, m) } else { format!("{}h", h) }; + return if m > 0 { + format!("{}h{}m", h, m) + } else { + format!("{}h", h) + }; } let (d, h) = (secs / 86_400, (secs % 86_400) / 3600); - if h > 0 { format!("{}d{}h", d, h) } else { format!("{}d", d) } + if h > 0 { + format!("{}d{}h", d, h) + } else { + format!("{}d", d) + } } #[cfg(test)] diff --git a/rust/src/input.rs b/rust/src/input.rs index 9b97912..1eee0e1 100644 --- a/rust/src/input.rs +++ b/rust/src/input.rs @@ -2,45 +2,67 @@ use serde::Deserialize; #[derive(Deserialize, Default)] pub struct RawInput { - #[serde(default)] pub model: Model, - #[serde(default)] pub context_window: ContextWindow, - #[serde(default)] pub transcript_path: String, - #[serde(default)] pub session_id: String, - #[serde(default)] pub rate_limits: RateLimits, + #[serde(default)] + pub model: Model, + #[serde(default)] + pub context_window: ContextWindow, + #[serde(default)] + pub transcript_path: String, + #[serde(default)] + pub session_id: String, + #[serde(default)] + pub rate_limits: RateLimits, } #[derive(Deserialize, Default)] -pub struct Model { #[serde(default)] pub display_name: String } +pub struct Model { + #[serde(default)] + pub display_name: String, +} #[derive(Deserialize, Default)] pub struct ContextWindow { - #[serde(default)] pub used_percentage: Option, - #[serde(default)] pub context_window_size: Option, - #[serde(default)] pub current_usage: Usage, + #[serde(default)] + pub used_percentage: Option, + #[serde(default)] + pub context_window_size: Option, + #[serde(default)] + pub current_usage: Usage, } #[derive(Deserialize, Default)] pub struct Usage { - #[serde(default)] pub input_tokens: u64, - #[serde(default)] pub output_tokens: u64, - #[serde(default)] pub cache_creation_input_tokens: u64, - #[serde(default)] pub cache_read_input_tokens: u64, + #[serde(default)] + pub input_tokens: u64, + #[serde(default)] + pub output_tokens: u64, + #[serde(default)] + pub cache_creation_input_tokens: u64, + #[serde(default)] + pub cache_read_input_tokens: u64, } #[derive(Deserialize, Default)] pub struct RateLimits { - #[serde(default)] pub five_hour: Option, - #[serde(default)] pub seven_day: Option, + #[serde(default)] + pub five_hour: Option, + #[serde(default)] + pub seven_day: Option, } #[derive(Deserialize, Default, Clone)] pub struct RlRaw { - #[serde(default)] pub used_percentage: Option, - #[serde(default)] pub resets_at: Option, + #[serde(default)] + pub used_percentage: Option, + #[serde(default)] + pub resets_at: Option, } #[derive(PartialEq, Debug)] -pub enum Client { ClaudeCode, Antigravity } +pub enum Client { + ClaudeCode, + Antigravity, +} pub struct Session { pub model: String, @@ -62,7 +84,8 @@ pub struct Session { impl Session { pub fn from_raw(r: RawInput) -> Session { let u = r.context_window.current_usage; - let tokens_used = u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens; + let tokens_used = + u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens; // Subagent transcript -> parent session (tokenline.sh:123-125) // Normalize Windows backslashes once, up front, so all contains()/replace() @@ -70,7 +93,8 @@ impl Session { let mut transcript_path = r.transcript_path.replace('\\', "/"); if transcript_path.contains("/subagents/") { if let Some(parent) = std::path::Path::new(&transcript_path) - .parent().and_then(|p| p.parent()) + .parent() + .and_then(|p| p.parent()) { transcript_path = format!("{}.jsonl", parent.display()); } @@ -152,7 +176,9 @@ mod tests { fn detects_gemini_and_antigravity() { let raw: RawInput = serde_json::from_str( r#"{"model":{"display_name":"Gemini 2.5 Pro"}, - "transcript_path":"/x/antigravity/y/z.jsonl"}"#).unwrap(); + "transcript_path":"/x/antigravity/y/z.jsonl"}"#, + ) + .unwrap(); let s = Session::from_raw(raw); assert!(s.is_gemini); assert_eq!(s.cli_client, Client::Antigravity); @@ -163,7 +189,9 @@ mod tests { fn normalizes_windows_backslash_paths() { // Backslash subagent path resolves to the parent .jsonl, same as the unix case. let raw: RawInput = serde_json::from_str( - r#"{"transcript_path":"C:\\Users\\x\\antigravity\\p\\subagents\\s\\t.jsonl"}"#).unwrap(); + r#"{"transcript_path":"C:\\Users\\x\\antigravity\\p\\subagents\\s\\t.jsonl"}"#, + ) + .unwrap(); let s = Session::from_raw(raw); assert_eq!(s.cli_client, Client::Antigravity); assert!(s.transcript_path.contains("/antigravity-cli/")); diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 33c96be..2fac72f 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -7,4 +7,4 @@ pub mod input; pub mod ratelimits; pub mod render; -pub use render::{render, Density}; +pub use render::{Density, render}; diff --git a/rust/src/main.rs b/rust/src/main.rs index 9250dad..48288fd 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,9 +1,9 @@ use std::io::Read; use std::io::Write; use std::time::{SystemTime, UNIX_EPOCH}; -use tokenline::{render, Density}; use tokenline::cache::runtime_dir; use tokenline::input::RawInput; +use tokenline::{Density, render}; fn main() { // Any panic anywhere becomes empty output + exit(0). Never disturb the host. @@ -21,13 +21,17 @@ fn main() { fn run() -> String { let mut buf = String::new(); - if std::io::stdin().read_to_string(&mut buf).is_err() { return String::new(); } + if std::io::stdin().read_to_string(&mut buf).is_err() { + return String::new(); + } let raw: RawInput = match serde_json::from_str(&buf) { Ok(r) => r, Err(_) => return String::new(), // malformed => silent no-op }; - let now = SystemTime::now().duration_since(UNIX_EPOCH) - .map(|d| d.as_secs() as i64).unwrap_or(0); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); let dir = runtime_dir(); render(raw, now, &dir, Density::Normal) } diff --git a/rust/src/ratelimits.rs b/rust/src/ratelimits.rs index 6cefa31..284c842 100644 --- a/rust/src/ratelimits.rs +++ b/rust/src/ratelimits.rs @@ -1,44 +1,78 @@ +use crate::epoch::iso8601_to_epoch; use crate::fmt::Role; use crate::input::RlRaw; -use crate::epoch::iso8601_to_epoch; #[derive(Debug)] -pub enum Pace { None, Fast, VeryFast } +pub enum Pace { + None, + Fast, + VeryFast, +} -pub struct RlWindow { pub pct: i64, pub eta_secs: i64, pub pace: Pace } +pub struct RlWindow { + pub pct: i64, + pub eta_secs: i64, + pub pace: Pace, +} /// tokenline.sh:345-363 pace heuristic, extracted for testability. pub fn pace_of(pct_int: i64, eta_secs: i64, window_secs: i64) -> Pace { - if pct_int < 20 || eta_secs <= 0 || window_secs <= 0 { return Pace::None; } + if pct_int < 20 || eta_secs <= 0 || window_secs <= 0 { + return Pace::None; + } let mut elapsed = window_secs - eta_secs; - if elapsed < 0 { elapsed = 0; } - if elapsed < window_secs / 10 { return Pace::None; } // need >=10% elapsed + if elapsed < 0 { + elapsed = 0; + } + if elapsed < window_secs / 10 { + return Pace::None; + } // need >=10% elapsed let pace = (pct_int as f64 * window_secs as f64) / (elapsed as f64 * 100.0); - if pace >= 1.5 { Pace::VeryFast } else if pace >= 1.25 { Pace::Fast } else { Pace::None } + if pace >= 1.5 { + Pace::VeryFast + } else if pace >= 1.25 { + Pace::Fast + } else { + Pace::None + } } pub fn rl_window(raw: &RlRaw, window_secs: i64, now: i64) -> Option { let pct = raw.used_percentage?; // None => no segment let pct_int = pct.round() as i64; - let eta_secs = raw.resets_at.as_deref() + let eta_secs = raw + .resets_at + .as_deref() .and_then(iso8601_to_epoch) .map(|r| (r - now).max(0)) .unwrap_or(0); - Some(RlWindow { pct: pct_int, eta_secs, pace: pace_of(pct_int, eta_secs, window_secs) }) + Some(RlWindow { + pct: pct_int, + eta_secs, + pace: pace_of(pct_int, eta_secs, window_secs), + }) } pub fn rl_bar(pct: i64, width: usize) -> (usize, usize) { let mut filled = (pct.max(0) as usize * width) / 100; - if filled > width { filled = width; } + if filled > width { + filled = width; + } (filled, width - filled) } pub fn rl_role(pct: i64) -> (Role, bool) { - if pct >= 90 { (Role::Critical, true) } - else if pct >= 75 { (Role::Critical, false) } - else if pct >= 50 { (Role::Caution, false) } - else if pct >= 25 { (Role::Warn, false) } - else { (Role::Good, false) } + if pct >= 90 { + (Role::Critical, true) + } else if pct >= 75 { + (Role::Critical, false) + } else if pct >= 50 { + (Role::Caution, false) + } else if pct >= 25 { + (Role::Warn, false) + } else { + (Role::Good, false) + } } #[cfg(test)] @@ -47,7 +81,10 @@ mod tests { use crate::input::RlRaw; fn raw(pct: f64, reset: &str) -> RlRaw { - RlRaw { used_percentage: Some(pct), resets_at: Some(reset.into()) } + RlRaw { + used_percentage: Some(pct), + resets_at: Some(reset.into()), + } } #[test] @@ -69,7 +106,7 @@ mod tests { // (brief's original numbers here, eta=15_000, actually yield elapsed=3000s // and pace=1.8 -> VeryFast; corrected to genuinely land in the Fast band) assert!(matches!(pace_of(30, 14_000, 18_000), Pace::Fast)); - assert!(matches!(pace_of(10, 1_000, 18_000), Pace::None)); // under 20% -> none + assert!(matches!(pace_of(10, 1_000, 18_000), Pace::None)); // under 20% -> none } #[test] diff --git a/rust/src/render.rs b/rust/src/render.rs index 7be2fb9..aa3658b 100644 --- a/rust/src/render.rs +++ b/rust/src/render.rs @@ -1,20 +1,28 @@ -use std::path::Path; -use crate::input::{RawInput, Session, Client}; -use crate::fmt::{Role, fmt_k, fmt_eta}; +use crate::cachetimer::{Warmth, cache_info}; use crate::economics::economics; -use crate::ratelimits::{rl_window, rl_bar, rl_role, Pace, RlWindow}; -use crate::cachetimer::{cache_info, Warmth}; +use crate::fmt::{Role, fmt_eta, fmt_k}; +use crate::input::{Client, RawInput, Session}; +use crate::ratelimits::{Pace, RlWindow, rl_bar, rl_role, rl_window}; +use std::path::Path; #[derive(Clone, Copy)] -pub enum Density { Compact, Normal, Verbose } +pub enum Density { + Compact, + Normal, + Verbose, +} struct Buf(String); impl Buf { - fn new() -> Self { Buf(String::new()) } + fn new() -> Self { + Buf(String::new()) + } fn seg(&mut self, role: Role, text: &str, bold: bool, blink: bool) { self.0.push_str(&role.paint(text, bold, blink)); } - fn take(self) -> String { self.0 } + fn take(self) -> String { + self.0 + } } /// Public entry. v1: always renders Normal (PULSE+); other densities fall back. @@ -52,9 +60,15 @@ fn render_normal(s: &Session, now: i64, cache_dir: &Path) -> String { l1.seg(cache_role, "♥ ", false, blink); l1.seg(Role::Muted, "cache ", false, false); l1.seg(cache_role, &clock(ci.remaining_secs), true, blink); - l1.seg(cache_role, &format!(" {} ", drain_bar(ci.remaining_secs, ttl_of(&ci))), false, false); + l1.seg( + cache_role, + &format!(" {} ", drain_bar(ci.remaining_secs, ttl_of(&ci))), + false, + false, + ); l1.seg(cache_role, word, blink, blink); - out.push_str(&l1.take()); out.push('\n'); + out.push_str(&l1.take()); + out.push('\n'); // ---- Line 2: cost thermometer (only when a turn happened) ------------ if eco.any_activity { @@ -64,11 +78,22 @@ fn render_normal(s: &Session, now: i64, cache_dir: &Path) -> String { l2.seg(role, &glyph, false, false); } l2.seg(Role::Accent, &format!(" {}", fmt_k(eco.eq)), true, false); - l2.seg(Role::Faint, &format!(" / {} full", fmt_k(eco.uncached_eq)), false, false); + l2.seg( + Role::Faint, + &format!(" / {} full", fmt_k(eco.uncached_eq)), + false, + false, + ); l2.seg(Role::Faint, " · ", false, false); let save_role = save_role(eco.saving_pct); - l2.seg(save_role, &format!("↓{}% saved", eco.saving_pct), true, false); - out.push_str(&l2.take()); out.push('\n'); + l2.seg( + save_role, + &format!("↓{}% saved", eco.saving_pct), + true, + false, + ); + out.push_str(&l2.take()); + out.push('\n'); } // ---- Line 3: rate limits (Anthropic only) ---------------------------- @@ -77,12 +102,17 @@ fn render_normal(s: &Session, now: i64, cache_dir: &Path) -> String { let w7 = s.rl_7d.as_ref().and_then(|r| rl_window(r, 604_800, now)); if w5.is_some() || w7.is_some() { let mut l3 = Buf::new(); - if let Some(w) = &w5 { rl_segment(&mut l3, "5h", w); } + if let Some(w) = &w5 { + rl_segment(&mut l3, "5h", w); + } if let Some(w) = &w7 { - if w5.is_some() { l3.seg(Role::Faint, " ", false, false); } + if w5.is_some() { + l3.seg(Role::Faint, " ", false, false); + } rl_segment(&mut l3, "7d", w); } - out.push_str(&l3.take()); out.push('\n'); + out.push_str(&l3.take()); + out.push('\n'); } } @@ -91,7 +121,11 @@ fn render_normal(s: &Session, now: i64, cache_dir: &Path) -> String { fn rl_segment(b: &mut Buf, label: &str, w: &RlWindow) { let (role, blink) = rl_role(w.pct); - let marker = if matches!(w.pace, Pace::VeryFast) || w.pct >= 75 { "▲ " } else { "▸ " }; + let marker = if matches!(w.pace, Pace::VeryFast) || w.pct >= 75 { + "▲ " + } else { + "▸ " + }; b.seg(role, marker, false, blink); b.seg(role, &format!("{} ", label), false, false); let (filled, empty) = rl_bar(w.pct, 10); @@ -104,19 +138,28 @@ fn rl_segment(b: &mut Buf, label: &str, w: &RlWindow) { Pace::None => {} } if w.eta_secs > 0 { - b.seg(Role::Faint, &format!("· {} left", fmt_eta(w.eta_secs)), false, false); + b.seg( + Role::Faint, + &format!("· {} left", fmt_eta(w.eta_secs)), + false, + false, + ); } } // ---- small helpers ------------------------------------------------------ fn ctx_role(pct: i64) -> (Role, bool) { - if pct >= 80 { (Role::Critical, true) } - else if pct >= 50 { (Role::Warn, false) } - else { (Role::Good, false) } + if pct >= 80 { + (Role::Critical, true) + } else if pct >= 50 { + (Role::Warn, false) + } else { + (Role::Good, false) + } } fn spark(pct: i64) -> String { // 2-cell rising meter: ▁▂▃▄▅▆▇█ picked by 8ths. - let blocks = ['▁','▂','▃','▄','▅','▆','▇','█']; + let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; let idx = ((pct.clamp(0, 100) * 8) / 100).clamp(0, 7) as usize; format!("{}{}", blocks[idx], blocks[idx.min(7)]) } @@ -131,21 +174,40 @@ fn clock(remaining: i64) -> String { let r = remaining.max(0); format!("{}:{:02}", r / 60, r % 60) } -fn ttl_of(ci: &crate::cachetimer::CacheInfo) -> i64 { if ci.is_1h { 3600 } else { 300 } } +fn ttl_of(ci: &crate::cachetimer::CacheInfo) -> i64 { + if ci.is_1h { 3600 } else { 300 } +} fn drain_bar(remaining: i64, ttl: i64) -> String { - let filled = if ttl > 0 { (remaining.max(0) * 10 / ttl).clamp(0, 10) as usize } else { 0 }; + let filled = if ttl > 0 { + (remaining.max(0) * 10 / ttl).clamp(0, 10) as usize + } else { + 0 + }; format!("{}{}", "█".repeat(filled), "░".repeat(10 - filled)) } fn save_role(pct: i64) -> Role { - if pct >= 70 { Role::Good } else if pct >= 50 { Role::Warn } else { Role::Caution } + if pct >= 70 { + Role::Good + } else if pct >= 50 { + Role::Warn + } else { + Role::Caution + } } /// Stacked cost thermometer: widths ∝ eq-contribution over ~11 cells, order read·new·write·output. fn thermometer(eco: &crate::economics::Economics) -> Vec<(String, Role)> { let total: f64 = eco.tiers.iter().map(|t| t.eq_contrib).sum::().max(1.0); let cells = 11.0; - eco.tiers.iter().map(|t| { - let n = (t.eq_contrib / total * cells).round() as usize; - let glyph = if n == 0 && t.eq_contrib > 0.0 { "·".to_string() } else { "▉".repeat(n) }; - (glyph, t.role) - }).collect() + eco.tiers + .iter() + .map(|t| { + let n = (t.eq_contrib / total * cells).round() as usize; + let glyph = if n == 0 && t.eq_contrib > 0.0 { + "·".to_string() + } else { + "▉".repeat(n) + }; + (glyph, t.role) + }) + .collect() } diff --git a/rust/tests/golden.rs b/rust/tests/golden.rs index ccb33bd..355ae19 100644 --- a/rust/tests/golden.rs +++ b/rust/tests/golden.rs @@ -1,13 +1,17 @@ use std::path::PathBuf; -use tokenline::{render, Density}; use tokenline::input::RawInput; +use tokenline::{Density, render}; fn strip_ansi(s: &str) -> String { let mut out = String::new(); let mut chars = s.chars(); while let Some(c) = chars.next() { if c == '\x1b' { - for c2 in chars.by_ref() { if c2 == 'm' { break; } } // skip ESC…m + for c2 in chars.by_ref() { + if c2 == 'm' { + break; + } + } // skip ESC…m } else { out.push(c); } @@ -15,7 +19,9 @@ fn strip_ansi(s: &str) -> String { out } -fn fixtures_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") } +fn fixtures_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} fn cache_scratch(label: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("tl-golden-{}-{}", std::process::id(), label)); @@ -26,7 +32,9 @@ fn cache_scratch(label: &str) -> PathBuf { #[test] fn healthy_claude_matches_golden() { let raw: RawInput = serde_json::from_str( - &std::fs::read_to_string(fixtures_dir().join("healthy_claude.json")).unwrap()).unwrap(); + &std::fs::read_to_string(fixtures_dir().join("healthy_claude.json")).unwrap(), + ) + .unwrap(); // FIXED now so the cache countdown is deterministic. let now = 1_783_000_000; let out = render(raw, now, &cache_scratch("healthy"), Density::Normal); @@ -40,8 +48,15 @@ fn gemini_hides_rate_limits() { let raw: RawInput = serde_json::from_str( r#"{"model":{"display_name":"Gemini 2.5 Pro"}, "context_window":{"used_percentage":9.0,"context_window_size":1000000, - "current_usage":{"cache_read_input_tokens":100,"output_tokens":50}}}"#).unwrap(); - let out = strip_ansi(&render(raw, 1_783_000_000, &cache_scratch("gemini"), Density::Normal)); + "current_usage":{"cache_read_input_tokens":100,"output_tokens":50}}}"#, + ) + .unwrap(); + let out = strip_ansi(&render( + raw, + 1_783_000_000, + &cache_scratch("gemini"), + Density::Normal, + )); assert!(!out.contains("5h"), "Gemini has no rate limits"); assert!(!out.contains("7d")); } @@ -50,8 +65,15 @@ fn gemini_hides_rate_limits() { fn no_activity_hides_cost_line() { let raw: RawInput = serde_json::from_str( r#"{"model":{"display_name":"Opus 4.8"}, - "context_window":{"used_percentage":1.0}}"#).unwrap(); - let out = strip_ansi(&render(raw, 1_783_000_000, &cache_scratch("noact"), Density::Normal)); + "context_window":{"used_percentage":1.0}}"#, + ) + .unwrap(); + let out = strip_ansi(&render( + raw, + 1_783_000_000, + &cache_scratch("noact"), + Density::Normal, + )); assert!(!out.contains("cost "), "no turn => no cost line"); } @@ -64,9 +86,26 @@ fn very_fast_pace_shows_eta_once() { r#"{"model":{"display_name":"Opus 4.8"}, "context_window":{"used_percentage":9.0}, "rate_limits":{"five_hour":{"used_percentage":88.0,"resets_at":"2026-07-02T16:16:40Z"}}}"#).unwrap(); - let out = strip_ansi(&render(raw, 1_783_000_000, &cache_scratch("pace"), Density::Normal)); - assert!(out.contains("⚡ pace!!"), "expected VeryFast pace flag:\n{}", out); - assert!(!out.contains("empties"), "ETA must not be duplicated via 'empties':\n{}", out); - assert_eq!(out.matches("left").count(), 1, "ETA must appear exactly once:\n{}", out); + let out = strip_ansi(&render( + raw, + 1_783_000_000, + &cache_scratch("pace"), + Density::Normal, + )); + assert!( + out.contains("⚡ pace!!"), + "expected VeryFast pace flag:\n{}", + out + ); + assert!( + !out.contains("empties"), + "ETA must not be duplicated via 'empties':\n{}", + out + ); + assert_eq!( + out.matches("left").count(), + 1, + "ETA must appear exactly once:\n{}", + out + ); } - diff --git a/rust/tests/never_crash.rs b/rust/tests/never_crash.rs index 5b0496d..1733c36 100644 --- a/rust/tests/never_crash.rs +++ b/rust/tests/never_crash.rs @@ -3,11 +3,22 @@ use std::process::{Command, Stdio}; fn run(stdin: &str) -> (i32, String) { let mut child = Command::new(env!("CARGO_BIN_EXE_tokenline")) - .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()) - .spawn().unwrap(); - child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap(); + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(stdin.as_bytes()) + .unwrap(); let out = child.wait_with_output().unwrap(); - (out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).into_owned()) + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + ) } #[test] @@ -32,5 +43,9 @@ fn valid_input_renders_and_exits_zero() { "cache_creation_input_tokens":2200,"cache_read_input_tokens":86600}}}"#); assert_eq!(code, 0); assert!(out.contains("Opus 4.8")); - assert!(out.contains("cost "), "expected the full PULSE+ render (cost line), got: {}", out); + assert!( + out.contains("cost "), + "expected the full PULSE+ render (cost line), got: {}", + out + ); } From f8bf36c93c12c1ff2e99152bc8587704d06deb3a Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 11:30:07 -0300 Subject: [PATCH 18/23] ci(rust): add fmt + clippy + test matrix + MSRV gate Adds .github/workflows/rust.yml gating rust/: rustfmt check, clippy (deny warnings) + cargo test across ubuntu/macos/windows, an MSRV build pinned to 1.85.0, and a fixed-name "Rust" fan-in gate job for branch protection. dtolnay/rust-toolchain pinned to commit SHAs per repo convention (matches actions/checkout pin in ci.yml). --- .github/workflows/rust.yml | 86 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/rust.yml diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..b549777 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,86 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Rust CI — gates the tokenline binary crate in rust/. Runs on every PR/push. +# fmt + clippy + test across Linux/macOS/Windows, plus an MSRV build on the +# declared floor (1.82). The Windows test leg runs the actual binary via stdin +# (tests/never_crash.rs) — that is the "runs on Windows" done-criteria. +# ───────────────────────────────────────────────────────────────────────────── +name: Rust + +on: + pull_request: + branches: [main] + push: + branches: [main] + +# Cancel stale PR runs; never cancel push-to-main runs. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +defaults: + run: + working-directory: rust + +jobs: + fmt: + name: rustfmt + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # stable + with: + components: rustfmt + - run: cargo fmt --check + + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # stable + with: + components: clippy + - name: Clippy (deny warnings) + run: cargo clippy --all-targets -- -D warnings + - name: Test (runs the built binary via stdin on this OS) + run: cargo test + + msrv: + name: MSRV (1.85) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: dtolnay/rust-toolchain@98effd2fc0b766278e30ab86762dd5e9a8531399 # 1.85.0 + - name: Build on the declared MSRV floor + run: cargo build --bins --lib + + # Thin fan-in gate. This exact string "Rust" is the required status check + # in branch protection, so it must stay a single fixed name that fans in over + # fmt + test + msrv. `if: always()` + explicit result checks make it a real + # pass/fail instead of a "skipped" that branch protection treats as green. + rust: + name: Rust + needs: [fmt, test, msrv] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Gate on the Rust jobs + working-directory: ${{ github.workspace }} + run: | + if [ "${{ needs.fmt.result }}" != "success" ] \ + || [ "${{ needs.test.result }}" != "success" ] \ + || [ "${{ needs.msrv.result }}" != "success" ]; then + echo "::error::Rust fmt, test matrix, or MSRV build did not pass." + exit 1 + fi From edc1ca6f7b27041b3b4514a9e130a19eb655bcb0 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 11:32:50 -0300 Subject: [PATCH 19/23] fix(ci): set explicit toolchain input on SHA-pinned rust-toolchain --- .github/workflows/rust.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index b549777..6905402 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,7 +1,7 @@ # ───────────────────────────────────────────────────────────────────────────── # Rust CI — gates the tokenline binary crate in rust/. Runs on every PR/push. # fmt + clippy + test across Linux/macOS/Windows, plus an MSRV build on the -# declared floor (1.82). The Windows test leg runs the actual binary via stdin +# declared floor (1.85). The Windows test leg runs the actual binary via stdin # (tests/never_crash.rs) — that is the "runs on Windows" done-criteria. # ───────────────────────────────────────────────────────────────────────────── name: Rust @@ -33,6 +33,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # stable with: + toolchain: stable components: rustfmt - run: cargo fmt --check @@ -48,6 +49,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # stable with: + toolchain: stable components: clippy - name: Clippy (deny warnings) run: cargo clippy --all-targets -- -D warnings @@ -61,6 +63,8 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: dtolnay/rust-toolchain@98effd2fc0b766278e30ab86762dd5e9a8531399 # 1.85.0 + with: + toolchain: "1.85.0" - name: Build on the declared MSRV floor run: cargo build --bins --lib From d5cf8da7b1d2a9f11e0d5cc4378e27267538ae37 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 11:42:56 -0300 Subject: [PATCH 20/23] build(rust): wire cargo-dist release pipeline dist-workspace.toml drives cargo-dist from the repo root at the rust/ crate. Generates release.yml (binary build matrix + shell/powershell installers + GitHub Release assets) on v* tags. Renames the npm changesets workflow to release-npm.yml to free the release.yml name; their tag/branch triggers do not overlap. --- .github/workflows/release-npm.yml | 44 +++++ .github/workflows/release.yml | 316 +++++++++++++++++++++++++++--- dist-workspace.toml | 18 ++ rust/Cargo.toml | 6 + 4 files changed, 352 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/release-npm.yml create mode 100644 dist-workspace.toml diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml new file mode 100644 index 0000000..0019a65 --- /dev/null +++ b/.github/workflows/release-npm.yml @@ -0,0 +1,44 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Release — triggered on pushes to main. +# Reads pending .changeset/*.md files, bumps versions, generates CHANGELOG.md, +# creates a "chore: version packages" PR, and publishes to NPM when merged. +# The PR title/commit are set to a conventional-commits form (lowercase subject) +# so the "PR title (semantic)" check passes on the auto-generated release PR. +# ───────────────────────────────────────────────────────────────────────────── +name: Release (npm) + +on: + push: + branches: + - main + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: write + pull-requests: write + +jobs: + release: + name: Version and Publish + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # Setup node + pnpm + install (Node from .nvmrc, pnpm from package.json) + - uses: ./.github/actions/setup-node-pnpm + + - name: Create Release Pull Request or Publish + id: changesets + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 + with: + # This triggers the "release" script we added to package.json + publish: pnpm run release + # Conventional-commits title for the auto-generated release PR, so the + # "PR title (semantic)" check passes (it requires a lowercase subject). + title: "chore: version packages" + commit: "chore: version packages" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f193528..1dfcd0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,44 +1,296 @@ -# ───────────────────────────────────────────────────────────────────────────── -# Release — triggered on pushes to main. -# Reads pending .changeset/*.md files, bumps versions, generates CHANGELOG.md, -# creates a "chore: version packages" PR, and publishes to NPM when merged. -# The PR title/commit are set to a conventional-commits form (lowercase subject) -# so the "PR title (semantic)" check passes on the auto-generated release PR. -# ───────────────────────────────────────────────────────────────────────────── +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + name: Release +permissions: + "contents": "write" +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. on: + pull_request: push: - branches: - - main + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v7 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v7 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json -concurrency: ${{ github.workflow }}-${{ github.ref }} + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" -permissions: - contents: write - pull-requests: write + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v7 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} -jobs: - release: - name: Version and Publish - runs-on: ubuntu-latest - timeout-minutes: 10 + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v8 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" - # Setup node + pnpm + install (Node from .nvmrc, pnpm from package.json) - - uses: ./.github/actions/setup-node-pnpm + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" - - name: Create Release Pull Request or Publish - id: changesets - uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v7 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v8 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v8 with: - # This triggers the "release" script we added to package.json - publish: pnpm run release - # Conventional-commits title for the auto-generated release PR, so the - # "PR title (semantic)" check passes (it requires a lowercase subject). - title: "chore: version packages" - commit: "chore: version packages" + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v7 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + announce: + needs: + - plan + - host + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..869f5be --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,18 @@ +[workspace] +members = ["cargo:rust"] + +[dist] +# Recorded by `dist init`; use the version from Step 2 verbatim. +cargo-dist-version = "0.32.0" +# curl|sh + irm|iex installers (Homebrew/npm/msi are later follow-ups). +installers = ["shell", "powershell"] +# Locked release targets. +targets = [ + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-musl", + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-pc-windows-msvc", +] +# CI backend that builds + publishes the above. +ci = "github" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 61f1b54..455f5a6 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" description = "A cache-aware statusline for AI coding CLIs" license = "MIT" +repository = "https://github.com/inbrace-tech/tokenline" rust-version = "1.85" # MSRV floor forced by edition 2024 (stabilized in 1.85); proven by CI msrv job [[bin]] @@ -23,3 +24,8 @@ opt-level = "z" lto = true strip = true panic = "unwind" # catch_unwind MUST work; abort would be a non-zero exit + +# The profile that 'dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" From 014891d3db65e8444457050d762d6c77b3241f9f Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 11:52:32 -0300 Subject: [PATCH 21/23] fix(dist): namespace release tags (tokenline-v*) to avoid npm tag collision The previous release.yml triggered on push tags matching '**[0-9]+.[0-9]+.[0-9]+*', which also matched npm changesets tags like @inbrace-tech/tokenline@1.2.0. Setting tag-namespace = "tokenline" in dist-workspace.toml anchors the trigger to a tokenline- prefix, renaming the generated workflow to tokenline-release.yml. --- .github/workflows/{release.yml => tokenline-release.yml} | 2 +- dist-workspace.toml | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) rename .github/workflows/{release.yml => tokenline-release.yml} (99%) diff --git a/.github/workflows/release.yml b/.github/workflows/tokenline-release.yml similarity index 99% rename from .github/workflows/release.yml rename to .github/workflows/tokenline-release.yml index 1dfcd0f..a34ff3d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/tokenline-release.yml @@ -42,7 +42,7 @@ on: pull_request: push: tags: - - '**[0-9]+.[0-9]+.[0-9]+*' + - 'tokenline**[0-9]+.[0-9]+.[0-9]+*' jobs: # Run 'dist plan' (or host) to determine what tasks we need to do diff --git a/dist-workspace.toml b/dist-workspace.toml index 869f5be..92e48bf 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -16,3 +16,7 @@ targets = [ ] # CI backend that builds + publishes the above. ci = "github" +# Anchor the release-tag trigger to a "tokenline-" prefix so binary tags +# (tokenline-v0.1.0) are provably disjoint from the npm changesets tags +# (@inbrace-tech/tokenline@x.y.z). Renames the workflow to tokenline-release.yml. +tag-namespace = "tokenline" From 6cd0b85e2973de85dc4e3af24aecefe948db66c8 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 11:57:11 -0300 Subject: [PATCH 22/23] docs(rust): add binary release runbook Also ignore the workspace-root target/ dir: cargo-dist builds there (not rust/target/, which is already covered), and running `dist build` locally left it untracked and one `git add -A` away from committing release archives/installers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 +++ rust/RELEASING.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 rust/RELEASING.md diff --git a/.gitignore b/.gitignore index 4fb5f53..899546a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ node_modules/ dist/ *.tgz +# Rust build artifacts (cargo-dist builds into the workspace root here, not rust/target/) +/target + # personal Claude Code overrides (the committed .claude/settings.json is the example) .claude/settings.local.json diff --git a/rust/RELEASING.md b/rust/RELEASING.md new file mode 100644 index 0000000..82b42f7 --- /dev/null +++ b/rust/RELEASING.md @@ -0,0 +1,40 @@ +# Releasing the `tokenline` binary + +The Rust binary ships on its own `tokenline-v*` semver track via cargo-dist, independent +of the npm package (which versions via changesets and tags `@inbrace-tech/tokenline@x.y.z`). + +The tag is namespaced (`tokenline-v0.1.0`, not bare `v0.1.0`) so the binary +release track is provably disjoint from the npm changesets tags — see +`tag-namespace = "tokenline"` in `dist-workspace.toml`. The release workflow +is `.github/workflows/tokenline-release.yml`. + +## Cutting a release + +1. Bump `version` in `rust/Cargo.toml` (starts at `0.1.0`; earns `1.0.0` at + full parity + Windows-verified). +2. Commit on `main`. +3. Tag and push: + ```bash + git tag tokenline-v0.1.0 + git push origin tokenline-v0.1.0 + ``` +4. `.github/workflows/tokenline-release.yml` (cargo-dist) fans out the build matrix for all + five targets, generates the `curl|sh` + `irm|iex` installers, and attaches the + archives + installers to a GitHub Release. + +## Targets + +`x86_64`/`aarch64-unknown-linux-musl` (static), `aarch64`/`x86_64-apple-darwin`, +`x86_64-pc-windows-msvc`. + +## Install (once a release exists) + +- Unix: `curl --proto '=https' --tlsv1.2 -LsSf https://github.com/inbrace-tech/tokenline/releases/latest/download/tokenline-installer.sh | sh` +- Windows: `irm https://github.com/inbrace-tech/tokenline/releases/latest/download/tokenline-installer.ps1 | iex` + +## Notes + +- Never set `panic = "abort"` in `[profile.dist]` — the never-crash guarantee + relies on `catch_unwind`, which needs `panic = "unwind"`. +- The bash `install.sh` path stays first-class; the binary is an additional + channel, not a replacement. From 1446abd4221d233fa193eb4613af9f4ca074f705 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 12:41:47 -0300 Subject: [PATCH 23/23] =?UTF-8?q?docs(spec):=20Plan=203=20design=20?= =?UTF-8?q?=E2=80=94=20npm=20delivers=20the=20Rust=20binary=20(optionalDep?= =?UTF-8?q?s=20+=20bash=20fallback)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-05-npm-binary-delivery-design.md | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-05-npm-binary-delivery-design.md diff --git a/docs/superpowers/specs/2026-07-05-npm-binary-delivery-design.md b/docs/superpowers/specs/2026-07-05-npm-binary-delivery-design.md new file mode 100644 index 0000000..63b42ab --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-npm-binary-delivery-design.md @@ -0,0 +1,116 @@ +# tokenline — npm Binary Delivery (Plan 3) Design + +**Date:** 2026-07-05 +**Status:** Approved (design) +**Branch:** `feat/rust-statusline` (Plans 1 + 2 already landed here) +**Predecessors:** Plan 1 (Rust core binary), Plan 2 (cargo-dist distribution + CI). Plan 2 already builds the 5-target binaries and uploads them to a GitHub Release on a `tokenline-v*` tag. + +## Goal + +Make the npm package `@inbrace-tech/tokenline` deliver the **Rust binary** instead of the bash `tokenline.sh`, while keeping bash first-class as a fallback. `init` wires the native binary into Claude Code's `settings.json`; `doctor` drops the `jq` check on the binary path. The binary reaches the user's machine via per-platform npm packages listed as `optionalDependencies` — **no runtime download, no postinstall fetch**. + +## Locked decisions (from the 2026-07-05 grill-me session + this brainstorm) + +1. **Delivery = hand-rolled `optionalDependencies` per platform (esbuild/napi pattern), with a bash fallback.** cargo-dist's own `npm` installer is explicitly **not** used: it fetches the binary at runtime (rejected — offline/proxy failure, security surface) and produces a separate package that does no `settings.json` wiring. The existing installer CLI (whose real value is the settings wiring) stays and is extended. +2. **Version convergence = unify on the binary tag, major bump to `2.0.0`.** The main package + the 5 platform packages publish together from the `tokenline-v*` tag at the same version. changesets is retired for this package (it is the only package). The delivery mechanism changed (bash → binary), so it is a breaking major. +3. **Bash stays first-class throughout.** On a platform with no prebuilt binary, `init` falls back to wiring `bash tokenline.sh` (still bundled in the package). No user is excluded. + +## Non-goals + +- Homebrew delivery (later). +- Nerd-font glyph mode (later). +- Changing the bash `install.sh` path (untouched — remains a first-class non-Node install route). +- Publishing a `libc`-constrained linux package: the linux binaries are static musl and run on any linux, so no `libc` field is needed. + +## Architecture + +cargo-dist (Plan 2) remains the sole builder/uploader of binaries → GitHub Release. Plan 3 adds the npm delivery layer on top: + +``` +tokenline-v2.0.0 tag + └─ tokenline-release.yml (cargo-dist, Plan 2): build 5 targets → GitHub Release assets + └─ (Release published) ──▶ publish-npm.yml (NEW, on: release: [published]): + download 5 release archives + ├─ build @inbrace-tech/tokenline-linux-x64@2.0.0 (os:linux cpu:x64) + ├─ build @inbrace-tech/tokenline-linux-arm64@2.0.0 (os:linux cpu:arm64) + ├─ build @inbrace-tech/tokenline-darwin-arm64@2.0.0 (os:darwin cpu:arm64) + ├─ build @inbrace-tech/tokenline-darwin-x64@2.0.0 (os:darwin cpu:x64) + ├─ build @inbrace-tech/tokenline-win32-x64@2.0.0 (os:win32 cpu:x64) + └─ build @inbrace-tech/tokenline@2.0.0 (main: dist/cli.js + tokenline.sh + optionalDependencies on the 5) + → npm publish all 6 at version 2.0.0 +``` + +At `npm install @inbrace-tech/tokenline`, npm installs only the platform package whose `os`/`cpu` match the host (others are skipped as unmet optionals). The CLI resolves the installed one via `require.resolve`. + +### Component 1 — Per-platform packages (built at release, not committed source) + +| cargo-dist target | npm package | `os` | `cpu` | +|---|---|---|---| +| `x86_64-unknown-linux-musl` | `@inbrace-tech/tokenline-linux-x64` | `linux` | `x64` | +| `aarch64-unknown-linux-musl` | `@inbrace-tech/tokenline-linux-arm64` | `linux` | `arm64` | +| `aarch64-apple-darwin` | `@inbrace-tech/tokenline-darwin-arm64` | `darwin` | `arm64` | +| `x86_64-apple-darwin` | `@inbrace-tech/tokenline-darwin-x64` | `darwin` | `x64` | +| `x86_64-pc-windows-msvc` | `@inbrace-tech/tokenline-win32-x64` | `win32` | `x64` | + +Each platform package is minimal: +```json +{ + "name": "@inbrace-tech/tokenline-", + "version": "2.0.0", + "os": [""], + "cpu": [""], + "files": ["tokenline"] // "tokenline.exe" on win32 +} +``` +plus the single binary. No `bin` field (we do not want it on `$PATH`; `init` copies it to a stable path). `publishConfig.access = public`. + +### Component 2 — Main package `@inbrace-tech/tokenline` + +- `version` → `2.0.0`. +- `optionalDependencies`: the 5 platform packages, each pinned to the exact same version (`2.0.0`). npm installs only the matching one. +- Keeps `files: ["dist", "tokenline.sh"]` and `bin: { tokenline: "dist/cli.js" }`. +- `engines.node: >=18` unchanged. Still effectively zero-runtime-dep: the optional platform packages carry only a binary + trivial `package.json`, and `require.resolve` is a Node built-in. +- Retire changesets: remove `release-npm.yml`, the `.changeset/` config, the `changeset`/`release` npm scripts, and `@changesets/cli` from devDependencies. Version now comes from the git tag at publish time. + +### Component 3 — CLI changes (`src/`) + +- **`core/paths.ts`** — add `resolveBinary(): string | null`. Maps `process.platform` + `process.arch` to a platform package name (the table above), then `require.resolve('/package.json')` and returns `dirname + '/tokenline'` (`tokenline.exe` on win32), or `null` if the package is not installed (unsupported platform / not yet published). Add a binary variant of `statusLineCommand`: for the binary it is just the (quote-if-spaces) binary path — **no `bash` prefix**. The bash-script variant is unchanged. +- **`commands/init.ts`** — resolve the binary first: + - **found:** copy the binary to a stable path (`/tokenline`, `tokenline.exe` on win32), `chmod 0o755` (unix), and write `command: `. Copying to a stable path (mirroring today's `.sh` copy) avoids pinning `settings.json` to a volatile `node_modules` path. + - **not found:** current behavior — copy `tokenline.sh`, write `command: bash