use chrono::{DateTime, Datelike, Local, TimeZone, Timelike, Utc};
use std::path::Path;
pub fn parse_ts(value: &str) -> Option<DateTime<Utc>> {
if value.is_empty() {
return None;
}
DateTime::parse_from_rfc3339(value)
.ok()
.map(|d| d.with_timezone(&Utc))
}
pub fn now_ms() -> i64 {
Utc::now().timestamp_millis()
}
pub fn ms_to_rfc3339(ms: i64) -> String {
DateTime::<Utc>::from_timestamp_millis(ms)
.map(|d| d.to_rfc3339())
.unwrap_or_default()
}
pub fn local_date_key(dt: &DateTime<Utc>) -> String {
let l = dt.with_timezone(&Local);
format!("{:04}-{:02}-{:02}", l.year(), l.month(), l.day())
}
pub fn local_hour_key(dt: &DateTime<Utc>) -> String {
let l = dt.with_timezone(&Local);
format!(
"{:04}-{:02}-{:02}T{:02}",
l.year(),
l.month(),
l.day(),
l.hour()
)
}
pub fn local_midnight_today() -> DateTime<Utc> {
let now = Local::now();
Local
.with_ymd_and_hms(now.year(), now.month(), now.day(), 0, 0, 0)
.single()
.map(|d| d.with_timezone(&Utc))
.unwrap_or_else(Utc::now)
}
pub fn days_in_current_month() -> u32 {
let now = Local::now();
let (y, m) = (now.year(), now.month());
let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
let first_next = chrono::NaiveDate::from_ymd_opt(ny, nm, 1).unwrap();
let first_this = chrono::NaiveDate::from_ymd_opt(y, m, 1).unwrap();
(first_next - first_this).num_days() as u32
}
pub fn relative_age(value: &str, now: &DateTime<Utc>) -> String {
let Some(parsed) = parse_ts(value) else {
return "n/a".into();
};
let secs = (now.timestamp() - parsed.timestamp()).max(0);
match secs {
s if s < 60 => "now".into(),
s if s < 3_600 => format!("{}m", s / 60),
s if s < 86_400 => format!("{}h", s / 3_600),
s if s < 604_800 => format!("{}d", s / 86_400),
s if s < 2_592_000 => format!("{}w", s / 604_800),
s if s < 31_536_000 => format!("{}mo", s / 2_592_000),
s => format!("{}y", s / 31_536_000),
}
}
pub fn compact_duration(ms: i64) -> String {
if ms <= 0 {
return "—".into();
}
let s = (ms as f64 / 1000.0).round() as i64;
if s < 60 {
format!("{s}s")
} else if s < 3_600 {
format!("{}m{:02}s", s / 60, s % 60)
} else {
format!("{}h{:02}m", s / 3_600, (s % 3_600) / 60)
}
}
pub fn long_duration(ms: i64) -> String {
let s = (ms.max(0) as f64 / 1000.0).round() as i64;
let (h, m, sec) = (s / 3_600, (s % 3_600) / 60, s % 60);
if h > 0 {
format!("{h}h {m}m {sec}s")
} else if m > 0 {
format!("{m}m {sec}s")
} else {
format!("{sec}s")
}
}
pub fn session_duration(started: &str, last: &str) -> String {
let (Some(start), Some(end)) = (
parse_ts(started),
parse_ts(last).or_else(|| parse_ts(started)),
) else {
return String::new();
};
let secs = (end.timestamp() - start.timestamp()).max(0);
match secs {
s if s < 60 => format!("{s}s"),
s if s < 3_600 => format!("{}m", s / 60),
s if s < 86_400 => format!("{}h", s / 3_600),
s if s < 604_800 => format!("{}d", s / 86_400),
s => format!("{}w", s / 604_800),
}
}
pub fn compact_tokens(value: u64) -> String {
match value {
v if v >= 1_000_000_000 => format!("{:.1}G", v as f64 / 1e9),
v if v >= 1_000_000 => format!("{:.1}M", v as f64 / 1e6),
v if v >= 1_000 => format!("{:.1}K", v as f64 / 1e3),
v => v.to_string(),
}
}
pub fn compact_bytes(value: u64) -> String {
const K: u64 = 1024;
match value {
0 => String::new(),
v if v >= K * K * K => format!("{:.1}G", v as f64 / (K * K * K) as f64),
v if v >= K * K => format!("{:.1}M", v as f64 / (K * K) as f64),
v if v >= K => format!("{:.0}K", v as f64 / K as f64),
v => format!("{v}B"),
}
}
pub fn compact_usd(value: f64) -> String {
format!("${:.2}", normalize_usd_zero(value))
}
pub fn adaptive_usd(value: f64) -> String {
let value = normalize_usd_zero(value);
if value > 0.0 && value < 0.01 {
format!("${value:.4}")
} else {
format!("${value:.2}")
}
}
fn normalize_usd_zero(value: f64) -> f64 {
if value == 0.0 || (-0.005..0.0).contains(&value) {
0.0
} else {
value
}
}
pub fn money(value: f64) -> String {
format!("{value:.6}")
}
pub fn token_cost(tokens: u64, rate_per_million: f64) -> f64 {
tokens as f64 * rate_per_million / 1_000_000.0
}
pub fn with_commas(n: u64) -> String {
let s = n.to_string();
let mut out = String::with_capacity(s.len() + s.len() / 3);
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
pub fn nice_max(val: f64) -> f64 {
if val <= 0.0 {
return 1.0;
}
let mag = 10f64.powf(val.log10().floor());
for step in [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 7.5, 10.0] {
if step * mag >= val {
return step * mag;
}
}
10.0 * mag
}
pub fn truncate(s: &str, width: usize) -> String {
if s.chars().count() <= width {
return s.to_string();
}
if width <= 1 {
return s.chars().take(width).collect();
}
let mut out: String = s.chars().take(width - 1).collect();
out.push('…');
out
}
pub fn tildify(path: &str) -> String {
let home = crate::config::HOME.to_string_lossy();
if !home.is_empty() && path.starts_with(home.as_ref()) {
let rest = &path[home.len()..];
if rest.is_empty() {
return "~".into();
}
if rest.starts_with('/') {
return format!("~{rest}");
}
}
path.to_string()
}
pub fn short_model(model: &str) -> String {
let m = model.strip_prefix("claude-").unwrap_or(model);
if m.len() > 9 {
let (head, tail) = m.split_at(m.len() - 9);
if tail.starts_with('-') && tail[1..].chars().all(|c| c.is_ascii_digit()) {
return head.to_string();
}
}
m.to_string()
}
fn path_parts(value: &str) -> Vec<&str> {
value.split(['/', '\\']).filter(|p| !p.is_empty()).collect()
}
pub fn abbreviate_paths(values: &[String]) -> Vec<String> {
let home = crate::config::HOME.to_string_lossy().to_string();
let home_parts = path_parts(&home);
let parts_list: Vec<Vec<&str>> = values
.iter()
.map(|v| {
let parts = path_parts(v);
if parts.len() >= home_parts.len()
&& home_parts.iter().enumerate().all(|(i, hp)| parts[i] == *hp)
{
let rest = parts[home_parts.len()..].to_vec();
if rest.is_empty() { vec!["~"] } else { rest }
} else {
parts
}
})
.collect();
let mut widths: Vec<usize> = parts_list
.iter()
.map(|p| usize::from(!p.is_empty()))
.collect();
loop {
let mut groups: std::collections::HashMap<String, Vec<usize>> =
std::collections::HashMap::new();
for (i, parts) in parts_list.iter().enumerate() {
if parts.is_empty() {
continue;
}
let label = parts[parts.len() - widths[i]..].join("/");
groups.entry(label).or_default().push(i);
}
let mut changed = false;
for indices in groups.values() {
if indices.len() < 2 {
continue;
}
for &idx in indices {
if widths[idx] < parts_list[idx].len() {
widths[idx] += 1;
changed = true;
}
}
}
if !changed {
break;
}
}
parts_list
.iter()
.enumerate()
.map(|(i, parts)| {
if parts.is_empty() {
"unknown".to_string()
} else {
parts[parts.len() - widths[i]..].join("/")
}
})
.collect()
}
pub fn pretty_mcp_name(name: &str) -> String {
let Some(without_prefix) = name.strip_prefix("mcp__") else {
return name.to_string();
};
let Some(sep) = without_prefix.find("__") else {
return without_prefix.replace('_', " ");
};
let server = &without_prefix[..sep];
let tool = &without_prefix[sep + 2..];
if crate::config::is_full_uuid(&server.to_ascii_lowercase()) {
return tool.replace('_', " ").trim().to_string();
}
let server_stripped = server
.strip_prefix("plugin_")
.and_then(|rest| {
let mid = rest.find('_')?;
let (a, b) = (&rest[..mid], &rest[mid + 1..]);
(a.eq_ignore_ascii_case(b)).then(|| a.to_string())
})
.unwrap_or_else(|| server.to_string());
let server_clean = server_stripped
.strip_suffix("_mcp")
.unwrap_or(&server_stripped)
.replace(['-', '_'], " ")
.trim()
.to_string();
let server_word = server_clean
.split(' ')
.next()
.unwrap_or("")
.to_ascii_lowercase();
let tool_stripped = tool
.strip_prefix(&format!("{server_word}_"))
.unwrap_or(tool);
let tool_clean = tool_stripped
.strip_suffix("_mcp")
.unwrap_or(tool_stripped)
.replace('_', " ")
.trim()
.to_string();
format!("{server_clean}: {tool_clean}")
}
const B64_ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub fn b64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let n = u32::from_be_bytes([0, b[0], b[1], b[2]]);
let idx = [(n >> 18) & 63, (n >> 12) & 63, (n >> 6) & 63, n & 63];
out.push(B64_ALPHABET[idx[0] as usize] as char);
out.push(B64_ALPHABET[idx[1] as usize] as char);
out.push(if chunk.len() > 1 {
B64_ALPHABET[idx[2] as usize] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
B64_ALPHABET[idx[3] as usize] as char
} else {
'='
});
}
out
}
pub fn b64_decode(input: &str) -> Option<Vec<u8>> {
let mut acc: u32 = 0;
let mut bits: u8 = 0;
let mut out = Vec::with_capacity(input.len() * 3 / 4);
for c in input.bytes() {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' | b'-' => 62,
b'/' | b'_' => 63,
b'=' | b'\n' | b'\r' => continue,
_ => return None,
};
acc = (acc << 6) | v as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
}
}
Some(out)
}
pub fn read_head(path: &Path, max_bytes: usize) -> Option<String> {
use std::io::Read;
let mut f = std::fs::File::open(path).ok()?;
let mut buf = vec![0u8; max_bytes];
let n = f.read(&mut buf).ok()?;
buf.truncate(n);
Some(String::from_utf8_lossy(&buf).into_owned())
}
pub fn read_tail(path: &Path, max_bytes: u64) -> Option<String> {
use std::io::{Read, Seek, SeekFrom};
let mut f = std::fs::File::open(path).ok()?;
let size = f.metadata().ok()?.len();
let want = max_bytes.min(size);
f.seek(SeekFrom::Start(size - want)).ok()?;
let mut buf = vec![0u8; want as usize];
f.read_exact(&mut buf).ok()?;
Some(String::from_utf8_lossy(&buf).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compact_formats() {
assert_eq!(compact_tokens(999), "999");
assert_eq!(compact_tokens(1_500), "1.5K");
assert_eq!(compact_tokens(2_400_000), "2.4M");
assert_eq!(compact_bytes(0), "");
assert_eq!(compact_bytes(2048), "2K");
assert_eq!(with_commas(1_234_567), "1,234,567");
}
#[test]
fn usd_does_not_display_negative_zero() {
assert_eq!(compact_usd(-0.0), "$0.00");
assert_eq!(adaptive_usd(-0.000_001), "$0.00");
assert_eq!(adaptive_usd(-0.01), "$-0.01");
}
#[test]
fn durations() {
assert_eq!(compact_duration(45_000), "45s");
assert_eq!(compact_duration(192_000), "3m12s");
assert_eq!(compact_duration(7_500_000), "2h05m");
assert_eq!(compact_duration(0), "—");
}
#[test]
fn epoch_millis_format_and_reject_out_of_range() {
assert_eq!(
parse_ts(&ms_to_rfc3339(1_700_000_000_000)).map(|d| d.timestamp_millis()),
Some(1_700_000_000_000)
);
assert_eq!(ms_to_rfc3339(0), "1970-01-01T00:00:00+00:00");
assert_eq!(ms_to_rfc3339(i64::MAX), "");
}
#[test]
fn model_shortening() {
assert_eq!(short_model("claude-opus-4-5-20251101"), "opus-4-5");
assert_eq!(short_model("claude-opus-5"), "opus-5");
assert_eq!(short_model("gpt-5.3-codex"), "gpt-5.3-codex");
assert_eq!(short_model("gpt-5.5"), "gpt-5.5");
}
#[test]
fn mcp_names() {
assert_eq!(
pretty_mcp_name("mcp__Claude_in_Chrome__tabs_context_mcp"),
"Claude in Chrome: tabs context"
);
assert_eq!(
pretty_mcp_name("mcp__github__search_code"),
"github: search code"
);
assert_eq!(pretty_mcp_name("mcp__slack__slack_send"), "slack: send");
assert_eq!(pretty_mcp_name("Bash"), "Bash");
}
#[test]
fn abbreviation_expands_only_on_collision() {
let paths = vec![
"/home/flo/work/api".to_string(),
"/home/flo/personal/api".to_string(),
"/home/flo/cctop".to_string(),
];
let out = abbreviate_paths(&paths);
assert_eq!(out[0], "work/api");
assert_eq!(out[1], "personal/api");
assert_eq!(out[2], "cctop"); }
#[test]
fn nice_maxima() {
assert_eq!(nice_max(0.0), 1.0);
assert_eq!(nice_max(87.0), 100.0);
assert_eq!(nice_max(230.0), 250.0);
}
}