From 7de7f0806f0c42418e0504390d2d1b6e77b98ee5 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 13:20:46 -0300 Subject: [PATCH 1/6] feat: scaffold Rust crate (edition 2024, serde + jiff) Slice 0 of the bottom-up Rust rewrite (#27): the crate skeleton so every later slice lands its module + test against a green build. - rust/ single binary crate 'tokenline', flat src/, edition 2024, MSRV 1.85. - deps: serde + serde_json + jiff (jiff::Timestamp for ISO-8601, lands slice 3). - panic = "unwind" in the release profile (the final slice's catch_unwind needs it). - main.rs is a stub; lib.rs is the module root. cargo build/run/test green (0 tests). --- rust/.gitignore | 1 + rust/Cargo.lock | 234 +++++++++++++++++++++++++++++++++++++++++++++++ rust/Cargo.toml | 27 ++++++ rust/src/lib.rs | 4 + rust/src/main.rs | 6 ++ 5 files changed, 272 insertions(+) create mode 100644 rust/.gitignore create mode 100644 rust/Cargo.lock create mode 100644 rust/Cargo.toml 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..a3dfa35 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,234 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" +dependencies = [ + "defmt", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-static" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6142247df1a93c2b3587402a19710be3e6e942f1581a1702e76408f2c21d6590" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[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 = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokenline" +version = "0.1.0" +dependencies = [ + "jiff", + "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 = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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..08945d4 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "tokenline" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +description = "A cache-aware statusline for AI coding CLIs" +license = "MIT" +repository = "https://github.com/inbrace-tech/tokenline" + +[[bin]] +name = "tokenline" +path = "src/main.rs" + +[lib] +name = "tokenline" +path = "src/lib.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +jiff = "0.2" + +[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/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..819b919 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,4 @@ +//! tokenline — a cache-aware statusline for AI coding CLIs. +//! +//! Pure domain (deterministic, golden-testable) and a thin stateful edge. +//! Modules land bottom-up, one slice per PR (see inbrace-tech/tokenline#27). diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 0000000..19193fd --- /dev/null +++ b/rust/src/main.rs @@ -0,0 +1,6 @@ +// tokenline — a cache-aware statusline for AI coding CLIs. +// +// Slice 0: scaffold only. The real edge (stdin → now → cache_dir → render → +// print → exit(0), with catch_unwind so a panic never crashes the host) lands +// in the final slice; the pure domain modules land bottom-up before it. +fn main() {} From faeeb3b3b24126b3d6ddeb23e64202bde1c067ff Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 13:22:27 -0300 Subject: [PATCH 2/6] feat: fmt_k / fmt_eta helpers (parity with tokenline.sh) Slice 1 (#27). Pure formatting helpers matching the bash fmt_k (awk %.1f token compaction) and fmt_eta (human ETA from seconds). Covered by #[cfg(test)] with the tier boundaries and the golden's 3d4h reset ETA. --- rust/src/fmt.rs | 73 +++++++++++++++++++++++++++++++++++++++++++++++++ rust/src/lib.rs | 2 ++ 2 files changed, 75 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..4a5f778 --- /dev/null +++ b/rust/src/fmt.rs @@ -0,0 +1,73 @@ +//! Formatting helpers, byte-for-byte parity with `tokenline.sh`. +//! +//! This slice carries `fmt_k` / `fmt_eta`; the 256-color palette and the +//! ISO-8601 → epoch parser land in the next two slices. + +/// Compact token count, mirroring the bash `fmt_k` (awk `%.1f`): +/// `1_500_000 -> "1.5M"`, `25_600 -> "25.6k"`, `900 -> "900"`. +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() + } +} + +/// Human ETA from raw seconds, mirroring the bash `fmt_eta`: +/// `<=0 -> "now"`, `<1h -> "%dm"`, `<1d -> "%dh%dm"/"%dh"`, else `"%dd%dh"/"%dd"`. +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_tiers() { + assert_eq!(fmt_k(0), "0"); + assert_eq!(fmt_k(900), "900"); + assert_eq!(fmt_k(999), "999"); + assert_eq!(fmt_k(1_000), "1.0k"); + assert_eq!(fmt_k(2_000), "2.0k"); + assert_eq!(fmt_k(2_200), "2.2k"); + assert_eq!(fmt_k(25_600), "25.6k"); + assert_eq!(fmt_k(86_600), "86.6k"); + assert_eq!(fmt_k(1_000_000), "1.0M"); + assert_eq!(fmt_k(1_500_000), "1.5M"); + } + + #[test] + fn fmt_eta_tiers() { + assert_eq!(fmt_eta(-5), "now"); + assert_eq!(fmt_eta(0), "now"); + assert_eq!(fmt_eta(30), "0m"); // %dm with integer division, like bash + assert_eq!(fmt_eta(90), "1m"); + assert_eq!(fmt_eta(3_600), "1h"); + assert_eq!(fmt_eta(3_660), "1h1m"); + assert_eq!(fmt_eta(86_400), "1d"); + assert_eq!(fmt_eta(90_000), "1d1h"); + assert_eq!(fmt_eta(273_600), "3d4h"); // 5h-window reset ETA in the golden + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 819b919..7d13ae2 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -2,3 +2,5 @@ //! //! Pure domain (deterministic, golden-testable) and a thin stateful edge. //! Modules land bottom-up, one slice per PR (see inbrace-tech/tokenline#27). + +pub mod fmt; From 50cf5afd55d054d50aae442b8616d87d6cf36e24 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 13:27:21 -0300 Subject: [PATCH 3/6] feat: 256-color palette (byte parity with tokenline.sh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 (#27). The COLOR_* / STYLE_* constants from tokenline.sh:21-30 as exact SGR sequences (RESET is [00m, BLINK [1;5m — byte-identical so the golden oracle diffs clean). Test asserts each against the bytes captured from bash output. --- rust/src/fmt.rs | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/rust/src/fmt.rs b/rust/src/fmt.rs index 4a5f778..1343ad6 100644 --- a/rust/src/fmt.rs +++ b/rust/src/fmt.rs @@ -1,7 +1,24 @@ //! Formatting helpers, byte-for-byte parity with `tokenline.sh`. //! -//! This slice carries `fmt_k` / `fmt_eta`; the 256-color palette and the -//! ISO-8601 → epoch parser land in the next two slices. +//! Carries `fmt_k` / `fmt_eta` and the 256-color palette; the ISO-8601 → epoch +//! parser lands in the next slice. + +/// 256-color SGR sequences, byte-identical to `tokenline.sh`'s `COLOR_*` / +/// `STYLE_*` constants (`tokenline.sh:21-30`). Byte parity matters: the golden +/// oracle diffs the raw escape sequences, so `RESET` is `[00m` (two digits), +/// not `[0m`, exactly as the bash emits. +pub mod color { + pub const GRAY: &str = "\x1b[38;5;244m"; + pub const DARK_GRAY: &str = "\x1b[38;5;240m"; + pub const CYAN: &str = "\x1b[38;5;51m"; + pub const YELLOW: &str = "\x1b[38;5;226m"; + pub const MAGENTA: &str = "\x1b[38;5;201m"; + pub const ORANGE: &str = "\x1b[38;5;208m"; + pub const RED: &str = "\x1b[38;5;196m"; + pub const GREEN: &str = "\x1b[38;5;46m"; + pub const RESET: &str = "\x1b[00m"; + pub const BLINK: &str = "\x1b[1;5m"; +} /// Compact token count, mirroring the bash `fmt_k` (awk `%.1f`): /// `1_500_000 -> "1.5M"`, `25_600 -> "25.6k"`, `900 -> "900"`. @@ -58,6 +75,22 @@ mod tests { assert_eq!(fmt_k(1_500_000), "1.5M"); } + #[test] + fn palette_matches_bash_bytes() { + // Exact bytes captured from `tokenline.sh` output (cat -v): ESC[38;5;Nm. + assert_eq!(color::GRAY, "\x1b[38;5;244m"); + assert_eq!(color::CYAN, "\x1b[38;5;51m"); + assert_eq!(color::YELLOW, "\x1b[38;5;226m"); + assert_eq!(color::MAGENTA, "\x1b[38;5;201m"); + assert_eq!(color::ORANGE, "\x1b[38;5;208m"); + assert_eq!(color::GREEN, "\x1b[38;5;46m"); + assert_eq!(color::RED, "\x1b[38;5;196m"); + assert_eq!(color::DARK_GRAY, "\x1b[38;5;240m"); + // bash COLOR_RESET is [00m (two digits), STYLE_BLINK is [1;5m. + assert_eq!(color::RESET, "\x1b[00m"); + assert_eq!(color::BLINK, "\x1b[1;5m"); + } + #[test] fn fmt_eta_tiers() { assert_eq!(fmt_eta(-5), "now"); From cfa0caf2450f5ba07f857de6ff392f8690d8564e Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 13:29:05 -0300 Subject: [PATCH 4/6] feat: ISO-8601 -> epoch via jiff Slice 3 (#27). Replaces the bash epoch_from_iso (date -d) with jiff::Timestamp parsing -> epoch seconds; None on malformed input. Transcripts and rate-limit resets are fixed-format UTC, so no tz database. Tests cover exact UTC epochs, sub-second truncation, and invalid input. --- rust/src/fmt.rs | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/rust/src/fmt.rs b/rust/src/fmt.rs index 1343ad6..16814c1 100644 --- a/rust/src/fmt.rs +++ b/rust/src/fmt.rs @@ -1,7 +1,17 @@ //! Formatting helpers, byte-for-byte parity with `tokenline.sh`. //! -//! Carries `fmt_k` / `fmt_eta` and the 256-color palette; the ISO-8601 → epoch -//! parser lands in the next slice. +//! Carries `fmt_k` / `fmt_eta`, the 256-color palette, and the ISO-8601 → epoch +//! parser. + +use jiff::Timestamp; + +/// Parse an RFC 3339 / ISO-8601 UTC timestamp to epoch seconds, or `None` on a +/// malformed value. Replaces the bash `epoch_from_iso` (`date -d`): transcripts +/// and rate-limit resets are fixed-format UTC, so `jiff::Timestamp` covers them +/// without pulling a timezone database. +pub fn iso8601_to_epoch(iso: &str) -> Option { + iso.parse::().ok().map(|t| t.as_second()) +} /// 256-color SGR sequences, byte-identical to `tokenline.sh`'s `COLOR_*` / /// `STYLE_*` constants (`tokenline.sh:21-30`). Byte parity matters: the golden @@ -75,6 +85,34 @@ mod tests { assert_eq!(fmt_k(1_500_000), "1.5M"); } + #[test] + fn iso8601_to_epoch_parses_utc() { + // Exact epochs (verified independently). now in the golden = 1_783_000_000. + assert_eq!( + iso8601_to_epoch("2026-07-05T18:00:00Z"), + Some(1_783_274_400) + ); + assert_eq!( + iso8601_to_epoch("2026-07-06T12:00:00Z"), + Some(1_783_339_200) + ); + assert_eq!( + iso8601_to_epoch("2026-07-05T00:00:00Z"), + Some(1_783_209_600) + ); + } + + #[test] + fn iso8601_to_epoch_fractional_and_invalid() { + // Sub-second precision truncates to the whole second. + assert_eq!( + iso8601_to_epoch("2026-07-05T18:00:00.500Z"), + Some(1_783_274_400) + ); + assert_eq!(iso8601_to_epoch("not a timestamp"), None); + assert_eq!(iso8601_to_epoch(""), None); + } + #[test] fn palette_matches_bash_bytes() { // Exact bytes captured from `tokenline.sh` output (cat -v): ESC[38;5;Nm. From 1396ac6db5046cb60200b97d4acab9948fae89ad Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 13:30:45 -0300 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20input=20parsing=20=E2=80=94=20serde?= =?UTF-8?q?-typed=20Session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4 (#27). Parses the host's stdin JSON into a typed Session (model, context, rate limits, per-turn token usage) with serde, plus the derivations (Gemini detection, subagent/antigravity transcript-path fixups) matching tokenline.sh. Unit-tested. --- rust/src/input.rs | 201 ++++++++++++++++++++++++++++++++++++++++++++++ rust/src/lib.rs | 2 + 2 files changed, 203 insertions(+) create mode 100644 rust/src/input.rs diff --git a/rust/src/input.rs b/rust/src/input.rs new file mode 100644 index 0000000..1eee0e1 --- /dev/null +++ b/rust/src/input.rs @@ -0,0 +1,201 @@ +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) + // 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()) + { + 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/")); + } + + #[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/src/lib.rs b/rust/src/lib.rs index 7d13ae2..4333d29 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -4,3 +4,5 @@ //! Modules land bottom-up, one slice per PR (see inbrace-tech/tokenline#27). pub mod fmt; + +pub mod input; From 68a547d4012e8912a596749c16e0818876a4c1e2 Mon Sep 17 00:00:00 2001 From: FabioRocha231 Date: Sun, 5 Jul 2026 13:34:50 -0300 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20economics=20=E2=80=94=20eq=20/=20sa?= =?UTF-8?q?ving=20%=20(pure=20compute,=20parity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 5 (#27). compute(session, ttl_label) -> Option: the per-turn equivalent-token and saving-% math byte-identical to tokenline.sh (multipliers, truncating eq/uncached_eq, integer saving_pct; write mult 2x on a 1h TTL). Pure values only — coloring is render's job. None when there's no turn activity. --- rust/src/economics.rs | 138 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 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..9d4d287 --- /dev/null +++ b/rust/src/economics.rs @@ -0,0 +1,138 @@ +use crate::input::Session; + +/// Pure compute for the per-turn token economics line (tokenline.sh +/// `compute_turn_breakdown`, lines ~388-441). No color/presentation here — +/// that's render.rs's job. +pub struct Economics { + pub read: u64, + pub write: u64, + pub new: u64, + pub output: u64, + pub eq: u64, + pub saving_pct: i64, + pub read_label: String, + pub write_label: String, + pub new_label: String, + pub output_label: String, +} + +/// `ttl_label` is the cache TTL label ("5m" or "1h") driving the write +/// multiplier bump for non-Gemini models. Returns `None` when there's no +/// token activity at all (bash's no-cost-line guard). +pub fn compute(s: &Session, ttl_label: &str) -> Option { + if s.cur_cread == 0 && s.cur_cwrite == 0 && s.cur_input == 0 && s.cur_output == 0 { + return None; + } + + // Multiplier value + its bash-literal label string (not derived from the + // float — bash assigns these as fixed strings, e.g. write_mult="2"). + let (read_mult, read_label, write_mult, write_label, new_mult, new_label, out_mult, out_label): ( + f64, + &str, + f64, + &str, + f64, + &str, + f64, + &str, + ) = if s.is_gemini { + (0.25, "0.25", 1.0, "1.0", 1.0, "1", 4.0, "4") + } else if ttl_label == "1h" { + (0.1, "0.1", 2.0, "2", 1.0, "1", 5.0, "5") + } else { + (0.1, "0.1", 1.25, "1.25", 1.0, "1", 5.0, "5") + }; + + // Match bash awk `printf "%d"` (truncation toward zero). + let eq = (s.cur_cread as f64 * read_mult + + s.cur_cwrite as f64 * write_mult + + s.cur_input as f64 * new_mult + + s.cur_output as f64 * out_mult) as u64; + + let uncached_eq = ((s.cur_cread + s.cur_cwrite + s.cur_input) as f64 * new_mult + + s.cur_output as f64 * out_mult) 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 + }; + + Some(Economics { + read: s.cur_cread, + write: s.cur_cwrite, + new: s.cur_input, + output: s.cur_output, + eq, + saving_pct, + read_label: format!("{read_label}x"), + write_label: format!("{write_label}x"), + new_label: format!("{new_label}x"), + output_label: format!("{out_label}x"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sess(json: &str) -> Session { + Session::from_raw(serde_json::from_str(json).unwrap()) + } + + #[test] + fn golden_fixture_5m_matches_bash() { + // read 86.6k, write 2.2k, new 2, output 2.0k, ttl 5m, non-Gemini + // eq = trunc(86600*0.1 + 2200*1.25 + 2*1 + 2000*5) + // = trunc(8660 + 2750 + 2 + 10000) = 21412 + // Wait: recompute precisely below via the fixture's actual numbers. + 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 = compute(&s, "5m").expect("activity present"); + assert_eq!(e.read, 86_600); + assert_eq!(e.write, 2_200); + assert_eq!(e.new, 2); + assert_eq!(e.output, 2_000); + assert_eq!(e.eq, 21_412); + assert_eq!(e.saving_pct, 78); + assert_eq!(e.read_label, "0.1x"); + assert_eq!(e.write_label, "1.25x"); + assert_eq!(e.new_label, "1x"); + assert_eq!(e.output_label, "5x"); + } + + #[test] + fn write_mult_bumps_to_2x_on_1h_ttl() { + 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 = compute(&s, "1h").expect("activity present"); + assert_eq!(e.eq, 2000); // 1000 * 2 + assert_eq!(e.write_label, "2x"); + } + + #[test] + fn gemini_multipliers() { + 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 = compute(&s, "5m").expect("activity present"); + // eq = 250 + 1000 + 1000 + 4000 = 6250 + assert_eq!(e.eq, 6250); + assert_eq!(e.read_label, "0.25x"); + assert_eq!(e.write_label, "1.0x"); + } + + #[test] + fn zero_activity_returns_none() { + let s = sess("{}"); + assert!(compute(&s, "5m").is_none()); + } +}