use crate::core::inspect;
use crate::core::session::{
detect_ghost_pollution, prune_missing_files, unix_now, GhostPollution, PruneReport, Session,
};
use crate::core::term;
use crate::core::tokens;
use anyhow::Result;
use rusqlite::{params, OptionalExtension};
use serde::Serialize;
pub struct MeterOpts {
pub history: bool,
pub graph: bool,
pub json: bool,
pub session_only: bool,
pub session_id: Option<String>,
pub prune: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
Lifetime,
Session,
}
#[derive(Debug, Serialize)]
pub struct PerFile {
pub file: String,
pub reads: i64,
pub tokens_full: i64,
pub tokens_sent: i64,
pub tokens_saved: i64,
pub reduction_pct: u32,
}
#[derive(Debug, Serialize)]
pub struct DayBucket {
pub day: String,
pub reads: i64,
pub tokens_full: i64,
pub tokens_sent: i64,
pub tokens_saved: i64,
pub reduction_pct: u32,
}
#[derive(Debug, Serialize)]
pub struct MeterReport {
pub scope: Scope,
pub session_id: String,
pub started_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_reset_at: Option<i64>,
pub elapsed_secs: i64,
pub files_tracked: i64,
pub total_reads: i64,
pub files_edited: i64,
pub total_edits: i64,
pub tokens_full: i64,
pub tokens_sent: i64,
pub tokens_saved: i64,
pub reduction_pct: u32,
pub dollars_saved: f64,
pub price_per_mtok: f64,
pub co2_g_saved: f64,
pub co2_g_per_ktok: f64,
pub top: Vec<PerFile>,
pub history: Option<Vec<DayBucket>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ghost_pollution: Option<GhostPollution>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_strategy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_context: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub storage: Option<crate::commands::cache::CacheStats>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compaction: Option<CompactionStats>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_edit_refreshes: Option<ExternalEditStats>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_picked_session: Option<AutoPickedSession>,
}
#[derive(Debug, Serialize)]
pub struct ExternalEditStats {
pub count: i64,
pub pct_of_reads: u32,
}
#[derive(Debug, Serialize)]
pub struct AutoPickedSession {
pub session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strategy: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CompactionStats {
pub total_compactions: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_compaction_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_compaction_age: Option<String>,
pub tokens_resent_after_compaction: i64,
}
pub fn run(opts: MeterOpts) -> Result<String> {
let prune = if opts.prune {
let s = Session::open()?;
Some(prune_missing_files(&s.conn)?)
} else {
None
};
let mut report = if opts.session_only {
collect_session_report(opts.history, opts.session_id.as_deref())?
} else {
collect_lifetime_report(opts.history)?
};
if !opts.session_only && prune.is_none() {
let s = Session::open_readonly()?;
report.ghost_pollution = detect_ghost_pollution(&s.conn)?;
}
if let Some(p) = &prune {
if opts.json {
let body = serde_json::to_value(&report)?;
let envelope = serde_json::json!({
"prune": prune_to_json(p),
"report": body,
});
return Ok(serde_json::to_string_pretty(&envelope)? + "\n");
}
}
if opts.json {
return Ok(serde_json::to_string_pretty(&report)? + "\n");
}
let mut out = String::new();
if let Some(p) = &prune {
out.push_str(&render_prune_notice(p));
out.push('\n');
}
out.push_str(&render_human(&report, opts.graph));
Ok(out)
}
fn prune_to_json(p: &PruneReport) -> serde_json::Value {
serde_json::json!({
"files_pruned": p.files_pruned,
"reads_reclaimed": p.reads_reclaimed,
"tokens_full_reclaimed": p.tokens_full_reclaimed,
"tokens_sent_reclaimed": p.tokens_sent_reclaimed,
"paths": p.paths,
})
}
fn render_ghost_hint(g: &GhostPollution) -> String {
let body = format!(
"{} ghost file(s) account for {}% of tracked file tokens — run `drip meter --prune` to drop them.\n",
g.ghost_files, g.ghost_pct,
);
term::yellow(&term::bold(&format!("⚠ {body}")))
}
fn render_prune_notice(p: &PruneReport) -> String {
if p.files_pruned == 0 {
return term::dim("Pruned 0 files (lifetime stats already clean).\n").to_string();
}
let mut s = String::new();
s.push_str(&term::green(&term::bold(&format!(
"Pruned {} file(s) (no longer on disk):\n",
p.files_pruned
))));
for path in p.paths.iter().take(5) {
s.push_str(&format!(" · {}\n", term::dim(path)));
}
if p.paths.len() > 5 {
s.push_str(&term::dim(&format!(
" · … and {} more\n",
p.paths.len() - 5
)));
}
s.push_str(&format!(
"Reclaimed {} tokens_full, {} tokens_sent, {} reads.\n",
format_compact(p.tokens_full_reclaimed),
format_compact(p.tokens_sent_reclaimed),
p.reads_reclaimed,
));
s
}
fn collect_lifetime_report(include_history: bool) -> Result<MeterReport> {
let session = Session::open_readonly()?;
let lifetime: Option<(i64, i64, i64, i64, i64)> = session
.conn
.query_row(
"SELECT installed_at, total_reads, tokens_full, tokens_sent,
external_edit_refreshes
FROM lifetime_stats WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
)
.optional()?;
let (installed_at, total_reads, tokens_full, tokens_sent, external_edit_refreshes) =
lifetime.unwrap_or((unix_now(), 0, 0, 0, 0));
let last_reset_at = crate::core::session::last_reset_at(&session.conn);
let anchor = last_reset_at.unwrap_or(installed_at);
let elapsed = (unix_now() - anchor).max(0);
let files_tracked: i64 = session
.conn
.query_row(
"SELECT COUNT(*) FROM lifetime_per_file WHERE tokens_full > 0",
[],
|r| r.get(0),
)
.unwrap_or(0);
let (files_edited, total_edits): (i64, i64) = session
.conn
.query_row(
"SELECT COUNT(*), COALESCE(SUM(edits), 0) FROM lifetime_edited_files",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap_or((0, 0));
let saved = tokens_full - tokens_sent;
let pct = tokens::percent_saved(tokens_full, tokens_sent);
let mut stmt = session.conn.prepare(
"SELECT file_path, reads, tokens_full, tokens_sent
FROM lifetime_per_file
WHERE tokens_full > tokens_sent
ORDER BY (tokens_full - tokens_sent) DESC
LIMIT 10",
)?;
let top: Vec<PerFile> = stmt
.query_map([], |r| {
let file: String = r.get(0)?;
let reads: i64 = r.get(1)?;
let tf: i64 = r.get(2)?;
let ts: i64 = r.get(3)?;
Ok(PerFile {
file,
reads,
tokens_full: tf,
tokens_sent: ts,
tokens_saved: (tf - ts).max(0),
reduction_pct: tokens::percent_saved(tf, ts),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let history = if include_history {
let cutoff = unix_now() - 30 * 86_400;
let cutoff_day = chrono_day(cutoff);
let mut stmt = session.conn.prepare(
"SELECT day, reads, tokens_full, tokens_sent
FROM lifetime_daily
WHERE day >= ?1
ORDER BY day DESC",
)?;
let rows: Vec<DayBucket> = stmt
.query_map(params![cutoff_day], |r| {
let day: String = r.get(0)?;
let reads: i64 = r.get(1)?;
let tf: i64 = r.get(2)?;
let ts: i64 = r.get(3)?;
Ok(DayBucket {
day,
reads,
tokens_full: tf,
tokens_sent: ts,
tokens_saved: (tf - ts).max(0),
reduction_pct: tokens::percent_saved(tf, ts),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Some(rows)
} else {
None
};
Ok(MeterReport {
scope: Scope::Lifetime,
session_id: String::new(),
started_at: installed_at,
last_reset_at,
elapsed_secs: elapsed,
files_tracked,
total_reads,
files_edited,
total_edits,
tokens_full,
tokens_sent,
tokens_saved: saved,
reduction_pct: pct,
dollars_saved: tokens::dollars_saved(saved),
price_per_mtok: tokens::price_per_mtok(),
co2_g_saved: tokens::co2_g_saved(saved),
co2_g_per_ktok: tokens::co2_g_per_ktok(),
top,
history,
ghost_pollution: None,
session_strategy: None,
session_context: None,
storage: crate::commands::cache::collect_stats().ok(),
compaction: collect_compaction_stats_lifetime(&session.conn)
.ok()
.flatten(),
external_edit_refreshes: external_edit_stats(external_edit_refreshes, total_reads),
auto_picked_session: None,
})
}
fn external_edit_stats(count: i64, total_reads: i64) -> Option<ExternalEditStats> {
if count <= 0 {
return None;
}
let pct = if total_reads > 0 {
((count as f64 / total_reads as f64) * 100.0).round() as u32
} else {
0
};
Some(ExternalEditStats {
count,
pct_of_reads: pct,
})
}
fn chrono_day(unix_ts: i64) -> String {
let secs_per_day = 86_400i64;
let days = unix_ts.div_euclid(secs_per_day);
let mut y: i64 = 1970;
let mut d = days;
let is_leap = |y: i64| (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
loop {
let len = if is_leap(y) { 366 } else { 365 };
if d < len {
break;
}
d -= len;
y += 1;
}
let months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut m = 0usize;
while m < 12 {
let mlen = months[m] + if m == 1 && is_leap(y) { 1 } else { 0 };
if d < mlen {
break;
}
d -= mlen;
m += 1;
}
format!("{:04}-{:02}-{:02}", y, m + 1, d + 1)
}
fn pick_inspect_session() -> Result<(Session, Option<AutoPickedSession>)> {
let (session, swap) = inspect::pick_session()?;
let auto = swap.map(|s| AutoPickedSession {
session_id: s.session_id,
agent: inspect::pretty_agent(s.agent),
strategy: s.strategy,
});
Ok((session, auto))
}
fn collect_session_report(include_history: bool, explicit_id: Option<&str>) -> Result<MeterReport> {
let (session, auto_picked_from) = match explicit_id {
Some(id) if !id.is_empty() => (Session::open_with_id_readonly(id.to_string())?, None),
_ => pick_inspect_session()?,
};
let started = session.started_at()?;
let elapsed = (unix_now() - started).max(0);
let (files_tracked, total_reads, reads_tf, reads_ts) = session.conn.query_row(
"SELECT COUNT(DISTINCT file_path),
COALESCE(SUM(reads_count), 0),
COALESCE(SUM(tokens_full), 0),
COALESCE(SUM(tokens_sent), 0)
FROM reads WHERE session_id = ?1",
params![session.id],
|r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, i64>(3)?,
))
},
)?;
let tokens_full = reads_tf;
let tokens_sent = reads_ts;
let saved = tokens_full - tokens_sent;
let pct = tokens::percent_saved(tokens_full, tokens_sent);
let mut stmt = session.conn.prepare(
"SELECT file_path, reads_count, tokens_full, tokens_sent
FROM reads
WHERE session_id = ?1 AND tokens_full > tokens_sent
ORDER BY (tokens_full - tokens_sent) DESC
LIMIT 10",
)?;
let top: Vec<PerFile> = stmt
.query_map(params![session.id], |r| {
let file: String = r.get(0)?;
let reads: i64 = r.get(1)?;
let tf: i64 = r.get(2)?;
let ts: i64 = r.get(3)?;
Ok(PerFile {
file,
reads,
tokens_full: tf,
tokens_sent: ts,
tokens_saved: (tf - ts).max(0),
reduction_pct: tokens::percent_saved(tf, ts),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let history = if include_history {
let cutoff = unix_now() - 30 * 86_400;
let mut stmt = session.conn.prepare(
"SELECT date(read_at, 'unixepoch'),
SUM(reads_count),
SUM(tokens_full),
SUM(tokens_sent)
FROM reads
WHERE read_at > ?1
GROUP BY 1
ORDER BY 1 DESC",
)?;
let rows: Vec<DayBucket> = stmt
.query_map(params![cutoff], |r| {
let day: String = r.get(0)?;
let reads: i64 = r.get(1)?;
let tf: i64 = r.get(2)?;
let ts: i64 = r.get(3)?;
Ok(DayBucket {
day,
reads,
tokens_full: tf,
tokens_sent: ts,
tokens_saved: (tf - ts).max(0),
reduction_pct: tokens::percent_saved(tf, ts),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Some(rows)
} else {
None
};
let strategy = session.strategy;
let context = session.context.clone();
let compaction = collect_compaction_stats_session(&session.conn, &session.id)
.ok()
.flatten();
let session_oob: i64 = session
.conn
.query_row(
"SELECT COUNT(*) FROM read_events
WHERE session_id = ?1
AND outcome_kind = 'fallback'
AND fallback_reason LIKE 'file changed externally%'",
params![session.id],
|r| r.get(0),
)
.unwrap_or(0);
Ok(MeterReport {
scope: Scope::Session,
session_id: session.id,
started_at: started,
last_reset_at: None,
elapsed_secs: elapsed,
files_tracked,
total_reads,
files_edited: 0,
total_edits: 0,
tokens_full,
tokens_sent,
tokens_saved: saved,
reduction_pct: pct,
dollars_saved: tokens::dollars_saved(saved),
price_per_mtok: tokens::price_per_mtok(),
co2_g_saved: tokens::co2_g_saved(saved),
co2_g_per_ktok: tokens::co2_g_per_ktok(),
top,
history,
ghost_pollution: None,
session_strategy: Some(strategy.as_str().to_string()),
session_context: Some(context),
storage: None,
compaction,
external_edit_refreshes: external_edit_stats(session_oob, total_reads),
auto_picked_session: auto_picked_from,
})
}
fn collect_compaction_stats_lifetime(
conn: &rusqlite::Connection,
) -> Result<Option<CompactionStats>> {
let row: Option<(i64, Option<i64>)> = conn
.query_row(
"SELECT COALESCE(SUM(compaction_count), 0),
MAX(last_compaction_at)
FROM sessions",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let (total, last_at) = row.unwrap_or((0, None));
if total == 0 {
return Ok(None);
}
let tokens_resent: i64 = conn
.query_row(
"SELECT COALESCE(SUM(tokens_full), 0)
FROM reads WHERE context_epoch > 0",
[],
|r| r.get(0),
)
.unwrap_or(0);
Ok(Some(CompactionStats {
total_compactions: total,
last_compaction_age: last_at.map(format_age),
last_compaction_at: last_at,
tokens_resent_after_compaction: tokens_resent,
}))
}
fn collect_compaction_stats_session(
conn: &rusqlite::Connection,
session_id: &str,
) -> Result<Option<CompactionStats>> {
let row: Option<(i64, Option<i64>)> = conn
.query_row(
"SELECT compaction_count, last_compaction_at
FROM sessions WHERE session_id = ?1",
params![session_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let (count, last_at) = match row {
Some(r) => r,
None => return Ok(None),
};
if count == 0 {
return Ok(None);
}
let tokens_resent: i64 = conn
.query_row(
"SELECT COALESCE(SUM(tokens_full), 0)
FROM reads
WHERE session_id = ?1 AND context_epoch > 0",
params![session_id],
|r| r.get(0),
)
.unwrap_or(0);
Ok(Some(CompactionStats {
total_compactions: count,
last_compaction_age: last_at.map(format_age),
last_compaction_at: last_at,
tokens_resent_after_compaction: tokens_resent,
}))
}
fn format_age(ts: i64) -> String {
let now = unix_now();
let delta = (now - ts).max(0);
if delta < 60 {
format!("{delta}s ago")
} else if delta < 3600 {
format!("{} min ago", delta / 60)
} else if delta < 86_400 {
format!("{}h ago", delta / 3600)
} else {
format!("{}d ago", delta / 86_400)
}
}
fn render_human(r: &MeterReport, graph: bool) -> String {
let mut out = String::new();
let label_w = 18;
let line_w = 66;
if let Some(g) = &r.ghost_pollution {
out.push_str(&render_ghost_hint(g));
out.push('\n');
}
let title = match r.scope {
Scope::Lifetime if r.last_reset_at.is_some() => "DRIP Token Savings (Since Reset)",
Scope::Lifetime => "DRIP Token Savings (Since Install)",
Scope::Session => "DRIP Token Savings (Current Session)",
};
out.push_str(&term::green(&term::bold(title)));
out.push('\n');
out.push_str(&term::dim(&"═".repeat(line_w)));
out.push('\n');
if let Some(pick) = &r.auto_picked_session {
let short_id: String = pick.session_id.chars().take(12).collect();
let agent_seg = pick
.agent
.as_deref()
.map(|a| format!(" ({a})"))
.unwrap_or_default();
out.push_str(&term::dim(&format!(
"ℹ Auto-picked session {short_id}…{agent_seg} — pass `--session <id>` to pin\n",
)));
}
out.push('\n');
write_stat(
&mut out,
label_w,
"Files tracked:",
&format_compact(r.files_tracked),
);
write_stat(
&mut out,
label_w,
"Total reads:",
&format_compact(r.total_reads),
);
if r.scope == Scope::Lifetime && (r.files_edited > 0 || r.total_edits > 0) {
let edits = format!(
"{} {}",
format_compact(r.files_edited),
term::dim(&format!("({} edits)", format_compact(r.total_edits))),
);
write_stat(&mut out, label_w, "Files edited:", &edits);
}
write_stat(
&mut out,
label_w,
"Tokens full:",
&format_compact(r.tokens_full),
);
write_stat(
&mut out,
label_w,
"Tokens sent:",
&format_compact(r.tokens_sent),
);
let saved_value = format_compact(r.tokens_saved);
let saved_pct = if r.tokens_saved < 0 {
String::from("(loss)")
} else {
format!("({}%)", r.reduction_pct)
};
let saved_colored = if r.tokens_saved <= 0 {
term::bold(&format!("{saved_value} {saved_pct}"))
} else {
match r.reduction_pct {
70..=u32::MAX => term::green(&term::bold(&format!("{saved_value} {saved_pct}"))),
30..=69 => term::yellow(&term::bold(&format!("{saved_value} {saved_pct}"))),
_ => term::bold(&format!("{saved_value} {saved_pct}")),
}
};
write_stat(&mut out, label_w, "Tokens saved:", &saved_colored);
if r.tokens_saved > 0 {
let dollars = format!(
"{} {}",
term::green(&format_dollars(r.dollars_saved)),
term::dim(&format!("(@ ${:.2}/Mtok)", r.price_per_mtok)),
);
write_stat(&mut out, label_w, "$ saved:", &dollars);
let co2 = format!(
"{} {}",
term::green(&format_co2(r.co2_g_saved)),
term::dim(&format!("(@ {:.2} g/Ktok)", r.co2_g_per_ktok)),
);
write_stat(&mut out, label_w, "CO₂ avoided:", &co2);
}
let span_label = match r.scope {
Scope::Lifetime if r.last_reset_at.is_some() => "Since reset:",
Scope::Lifetime => "Since install:",
Scope::Session => "Session age:",
};
write_stat(
&mut out,
label_w,
span_label,
&term::dim(&human_duration(r.elapsed_secs)),
);
if let Some(c) = &r.compaction {
let detail = match (&c.last_compaction_age, c.tokens_resent_after_compaction) {
(Some(age), n) if n > 0 => {
term::dim(&format!("(last {age}, {} re-sent)", format_compact(n)))
}
(Some(age), _) => term::dim(&format!("(last {age})")),
(None, _) => String::new(),
};
let body = if detail.is_empty() {
format_compact(c.total_compactions)
} else {
format!("{} {detail}", format_compact(c.total_compactions))
};
write_stat(&mut out, label_w, "Compactions:", &body);
}
if let Some(e) = &r.external_edit_refreshes {
let body = format!(
"{} {}",
format_compact(e.count),
term::dim(&format!(
"({}% of reads — file changed since last read, full content re-shipped)",
e.pct_of_reads
)),
);
write_stat(&mut out, label_w, "Native refresh:", &body);
}
let meter = meter_bar(r.reduction_pct, 16);
let meter_pct = match r.reduction_pct {
70..=u32::MAX => term::green(&format!("{}%", r.reduction_pct)),
30..=69 => term::yellow(&format!("{}%", r.reduction_pct)),
_ => term::dim(&format!("{}%", r.reduction_pct)),
};
out.push_str(&format!(
"{:<label_w$} {meter} {meter_pct}\n",
"Efficiency meter:",
label_w = label_w,
));
if !r.top.is_empty() {
out.push('\n');
out.push_str(&term::green(&term::bold("Top Files")));
out.push('\n');
out.push_str(&term::dim(&"─".repeat(line_w)));
out.push_str("\n\n");
let max_saved = r.top.iter().map(|f| f.tokens_saved).max().unwrap_or(0);
out.push_str(&term::dim(&format!(
" {:>3} {:<32} {:>5} {:>7} {:>9} {}\n",
"#", "File", "Reads", "Saved", "Reduction", "Impact"
)));
for (i, f) in r.top.iter().enumerate() {
let n = format!("{}.", i + 1);
let file = pad_visible(&truncate_label(&f.file, 32), 32);
let reads = format_compact(f.reads);
let saved_plain = format_compact(f.tokens_saved);
let saved_pad = " ".repeat(7usize.saturating_sub(saved_plain.len()));
let saved_colored = term::green(&saved_plain);
let pct_plain = format!("{}%", f.reduction_pct);
let pct_pad = " ".repeat(9usize.saturating_sub(pct_plain.len()));
let pct_colored = term::color_pct(f.reduction_pct);
let impact = impact_bar(f.tokens_saved, max_saved, 10);
out.push_str(&format!(
" {n:>3} {file} {reads:>5} {saved_pad}{saved_colored} {pct_pad}{pct_colored} {impact}\n",
));
}
} else {
out.push('\n');
out.push_str(&term::dim(
" no per-file savings yet — DRIP wins on the second read of a file.\n",
));
}
if let Some(history) = &r.history {
out.push('\n');
out.push_str(&term::green(&term::bold("History (last 30 days)")));
out.push('\n');
out.push_str(&term::dim(&"─".repeat(line_w)));
out.push_str("\n\n");
for d in history {
out.push_str(&format!(
" {} reads={:<4} saved={:<8} {}\n",
d.day,
d.reads,
format_compact(d.tokens_saved),
term::color_pct(d.reduction_pct),
));
}
}
if graph {
out.push('\n');
out.push_str(&render_graph(r.tokens_full, r.tokens_sent));
}
out
}
fn write_stat(out: &mut String, label_w: usize, label: &str, value: &str) {
out.push_str(&format!(
"{label:<label_w$} {value}\n",
label = label,
label_w = label_w,
value = value,
));
}
fn format_compact(n: i64) -> String {
let abs = n.unsigned_abs() as f64;
let sign = if n < 0 { "-" } else { "" };
if abs >= 1_000_000_000.0 {
format!("{sign}{:.1}B", abs / 1_000_000_000.0)
} else if abs >= 1_000_000.0 {
format!("{sign}{:.1}M", abs / 1_000_000.0)
} else if abs >= 10_000.0 {
format!("{sign}{:.1}K", abs / 1_000.0)
} else if abs >= 1_000.0 {
format!("{sign}{:.2}K", abs / 1_000.0)
} else {
format!("{sign}{}", n.unsigned_abs())
}
}
fn meter_bar(pct: u32, width: usize) -> String {
let pct = pct.min(100) as usize;
let filled = (pct * width + 50) / 100;
let empty = width.saturating_sub(filled);
let filled_str: String = "█".repeat(filled);
let empty_str: String = "▒".repeat(empty);
format!("{}{}", term::green(&filled_str), term::dim(&empty_str))
}
fn impact_bar(saved: i64, max_saved: i64, width: usize) -> String {
if max_saved <= 0 || saved <= 0 {
return " ".repeat(width);
}
let frac = (saved.min(max_saved) as f64) / (max_saved as f64);
let filled = (((frac * width as f64).round()) as usize).clamp(1, width);
let filled_str: String = "▓".repeat(filled);
let empty_str: String = "░".repeat(width - filled);
format!("{}{}", term::green(&filled_str), term::dim(&empty_str))
}
fn format_dollars(usd: f64) -> String {
if usd >= 1.0 {
format!("${}", format_thousands(usd.round() as i64))
} else {
format!("${:.2}", usd)
}
}
fn format_co2(grams: f64) -> String {
if grams >= 1_000_000.0 {
format!("{:.2} t", grams / 1_000_000.0)
} else if grams >= 1_000.0 {
format!("{:.2} kg", grams / 1_000.0)
} else if grams >= 10.0 {
format!("{:.0} g", grams)
} else {
format!("{:.1} g", grams)
}
}
fn format_thousands(n: i64) -> String {
let s = n.abs().to_string();
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len() + s.len() / 3);
if n < 0 {
out.push('-');
}
let len = bytes.len();
for (i, b) in bytes.iter().enumerate() {
if i > 0 && (len - i) % 3 == 0 {
out.push(',');
}
out.push(*b as char);
}
out
}
fn truncate_label(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let take = max.saturating_sub(2);
let tail: String = s
.chars()
.rev()
.take(take)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("..{tail}")
}
}
fn pad_visible(s: &str, width: usize) -> String {
let visible = s.chars().count();
if visible >= width {
s.to_string()
} else {
let mut out = String::with_capacity(s.len() + (width - visible));
out.push_str(s);
for _ in 0..(width - visible) {
out.push(' ');
}
out
}
}
fn human_duration(secs: i64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{} min", secs / 60)
} else if secs < 86_400 {
format!("{}h{}m", secs / 3600, (secs % 3600) / 60)
} else {
format!("{}d", secs / 86_400)
}
}
fn render_graph(full: i64, sent: i64) -> String {
if full <= 0 {
return "No data yet.\n".into();
}
let width = 40usize;
let sent_fill = ((sent as f64 / full as f64) * width as f64).round() as usize;
let saved_fill = width - sent_fill.min(width);
let bar = "█".repeat(sent_fill.min(width)) + &"░".repeat(saved_fill);
format!("Tokens [{bar}]\n sent={sent} / full={full}\n")
}