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/fmt.rs b/rust/src/fmt.rs new file mode 100644 index 0000000..16814c1 --- /dev/null +++ b/rust/src/fmt.rs @@ -0,0 +1,144 @@ +//! Formatting helpers, byte-for-byte parity with `tokenline.sh`. +//! +//! 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 +/// 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"`. +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 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. + 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"); + 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/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 new file mode 100644 index 0000000..4333d29 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,8 @@ +//! 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). + +pub mod fmt; + +pub mod input; 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() {}