use crate::config::SavingsStyle;
use crate::util::{format_duration, write_atomic};
use bytesize::ByteSize;
use eyre::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub(crate) const TALLY_FILE: &str = "savings/v1/tally.json";
const TALLY_LOCK: &str = "savings/v1/tally.lock";
const TALLY_VERSION: u8 = 1;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub(crate) struct Tally {
pub version: u8,
pub since_secs: u64,
pub builds: u64,
pub cached_compilations: u64,
pub avoided_compiler_ns: u64,
pub reflinked_bytes: u64,
pub freed_target_bytes: u64,
pub freed_store_bytes: u64,
pub freed_requested_bytes: u64,
#[serde(flatten)]
unknown: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Delta {
pub builds: u64,
pub cached_compilations: u64,
pub avoided_compiler_ns: u64,
pub reflinked_bytes: u64,
pub freed_target_bytes: u64,
pub freed_store_bytes: u64,
pub freed_requested_bytes: u64,
}
impl Delta {
fn is_empty(&self) -> bool {
*self == Self::default()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct SessionFacts {
pub hits: u64,
pub avoided_compiler_ns: u64,
}
fn tally_path(store: &Path) -> PathBuf {
store.join(TALLY_FILE)
}
pub(crate) fn record(store: &Path, delta: &Delta) -> Result<Tally> {
let lock_path = store.join(TALLY_LOCK);
std::fs::create_dir_all(
lock_path
.parent()
.expect("the tally lock path has a parent"),
)
.wrap_err_with(|| format!("failed to create {}", lock_path.display()))?;
let mut lock = fslock::LockFile::open(&lock_path)?;
lock.lock()?;
let path = tally_path(store);
let mut tally = read(&path);
if tally.version == 0 {
tally.version = TALLY_VERSION;
tally.since_secs = now_secs();
}
tally.builds = tally.builds.saturating_add(delta.builds);
tally.cached_compilations = tally
.cached_compilations
.saturating_add(delta.cached_compilations);
tally.avoided_compiler_ns = tally
.avoided_compiler_ns
.saturating_add(delta.avoided_compiler_ns);
tally.reflinked_bytes = tally.reflinked_bytes.saturating_add(delta.reflinked_bytes);
tally.freed_target_bytes = tally
.freed_target_bytes
.saturating_add(delta.freed_target_bytes);
tally.freed_store_bytes = tally
.freed_store_bytes
.saturating_add(delta.freed_store_bytes);
tally.freed_requested_bytes = tally
.freed_requested_bytes
.saturating_add(delta.freed_requested_bytes);
let mut contents = serde_json::to_vec_pretty(&tally)?;
contents.push(b'\n');
write_atomic(&path, &contents)?;
Ok(tally)
}
fn read(path: &Path) -> Tally {
std::fs::read(path)
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
pub(crate) fn read_tally(store: &Path) -> Tally {
read(&tally_path(store))
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.as_secs())
.unwrap_or_default()
}
const MIN_SESSION_HITS: u64 = 5;
const MIN_SESSION_AVOIDED: Duration = Duration::from_secs(30);
const MIN_LIFETIME_AVOIDED: Duration = Duration::from_secs(30 * 60);
const MIN_FREED_BYTES: u64 = 1024 * 1024 * 1024;
const MIN_FREED_TARGET_BYTES: u64 = 5 * 1024 * 1024 * 1024;
const MIN_REFLINKED_BYTES: u64 = 1024 * 1024 * 1024;
const MIN_CACHED_COMPILATIONS: u64 = 250;
macro_rules! tellings {
($($line:literal),+ $(,)?) => { vec![$(format!($line)),+] };
}
struct Candidate {
cheeky: Vec<String>,
plain: String,
}
fn facts_worth_telling(tally: &Tally, facts: &SessionFacts) -> Vec<Candidate> {
let freed = tally
.freed_target_bytes
.saturating_add(tally.freed_store_bytes);
let mut eligible = Vec::new();
if facts.hits >= MIN_SESSION_HITS
&& facts.avoided_compiler_ns >= duration_ns(MIN_SESSION_AVOIDED)
{
let hits = facts.hits;
let dur = nanos(facts.avoided_compiler_ns);
eligible.push(Candidate {
cheeky: tellings![
"mbx[savings]: served {hits} compilations from cache; rustc showed up ready to do {dur} of work and was sent home",
"mbx[savings]: {hits} compilations you did not wait for. the {dur} is yours now. spend it wisely.",
"mbx[savings]: this build borrowed {hits} compilations from an earlier one. rustc was not informed. ({dur} saved)",
"mbx[savings]: {hits} compilations arrived precompiled. somewhere a fan is not spinning. ({dur} saved)",
"mbx[savings]: cargo asked for {hits} compilations; mbx had them filed under already-done. ({dur} saved)",
"mbx[savings]: {hits} compilations replayed from cache. rustc's services were not required. ({dur} saved)",
"mbx[savings]: intercepted {hits} compilations before rustc could get attached. ({dur} saved)",
"mbx[savings]: rustc had {dur} of work planned. mbx had {hits} of its answers already.",
"mbx[savings]: {hits} compilations answered from memory. rustc can finish its coffee. ({dur} saved)",
"mbx[savings]: rustc arrived to find {hits} compilations already done. awkward. ({dur} saved)",
"mbx[savings]: quietly substituted {hits} cached compilations. nobody noticed. that is the job. ({dur} saved)",
"mbx[savings]: {hits} compilations in, zero compiled. {dur} returned to circulation.",
],
plain: format!(
"mbx[savings]: {hits} compilations restored from cache this build, avoiding {dur} of compiler time"
),
});
}
if tally.avoided_compiler_ns >= duration_ns(MIN_LIFETIME_AVOIDED) {
let dur = nanos(tally.avoided_compiler_ns);
let over = builds(tally.builds);
eligible.push(Candidate {
cheeky: tellings![
"mbx[savings]: {dur} of compiling skipped across {over}. rustc suspects nothing.",
"mbx[savings]: {dur} refunded over {over}. no receipt necessary.",
"mbx[savings]: rustc believes it compiled everything. it is down {dur} across {over}. let it believe.",
"mbx[savings]: has saved {dur} across {over}. this line is the only thanks it needs.",
"mbx[savings]: across {over}, rustc has been excused from {dur} of its duties.",
"mbx[savings]: {dur} not spent compiling, over {over}. what you did instead is between you and the terminal.",
"mbx[savings]: the running total stands at {dur} across {over}. nobody is counting except the box.",
"mbx[savings]: {dur} of compilation avoided across {over}. imagine the fan noise. now stop.",
"mbx[savings]: {dur} saved across {over}. put it toward something with a progress bar.",
"mbx[savings]: {dur} of rustc's calendar cleared across {over}.",
"mbx[savings]: {dur} unspent across {over}. compounding, in a sense.",
],
plain: format!("mbx[savings]: {dur} of compiler time avoided across {over}"),
});
}
if freed >= MIN_FREED_BYTES {
let size = iec(freed);
eligible.push(Candidate {
cheeky: tellings![
"mbx[savings]: {size} of build debris binned so far. cargo clean remains unemployed.",
"mbx[savings]: has quietly disposed of {size} of build leftovers. nobody saw anything.",
"mbx[savings]: {size} reclaimed to date. the disk sends its regards.",
"mbx[savings]: {size} of build debris has left the building.",
"mbx[savings]: swept up {size} so far. the broom is content-addressed.",
"mbx[savings]: {size} tidied away. you may continue not thinking about it.",
"mbx[savings]: {size} reclaimed while you were busy compiling other things.",
"mbx[savings]: {size} of old build output has been shown the door.",
"mbx[savings]: disposed of {size} without being asked. that is rather the point.",
"mbx[savings]: the sweep found {size} nobody would miss. nobody has.",
],
plain: format!("mbx[savings]: {size} reclaimed by collection so far"),
});
}
if tally.freed_target_bytes >= MIN_FREED_TARGET_BYTES {
let size = iec(tally.freed_target_bytes);
eligible.push(Candidate {
cheeky: tellings![
"mbx[savings]: {size} of target/ had outlived its checkouts. it has been dealt with.",
"mbx[savings]: found {size} of target/ whose checkouts left long ago. the estate has been settled.",
"mbx[savings]: your deleted worktrees left {size} behind. left. past tense.",
"mbx[savings]: {size} of target/ belonged to checkouts that no longer exist. neither does it.",
"mbx[savings]: cleaned up {size} after checkouts that left without saying goodbye.",
"mbx[savings]: {size} of outputs had survived their checkouts. an unnatural state, now corrected.",
"mbx[savings]: the checkouts are gone. their {size} has gone to join them.",
"mbx[savings]: {size} of target/ was waiting for checkouts that are never coming back. it has stopped waiting.",
"mbx[savings]: escorted {size} of ownerless outputs off the premises.",
"mbx[savings]: worktrees come and go. their {size} now goes with them.",
],
plain: format!("mbx[savings]: {size} of abandoned target directories reclaimed"),
});
}
if tally.reflinked_bytes >= MIN_REFLINKED_BYTES {
let size = iec(tally.reflinked_bytes);
eligible.push(Candidate {
cheeky: tellings![
"mbx[savings]: every checkout believes it owns {size} of outputs. the disk keeps one copy and says nothing.",
"mbx[savings]: {size} of outputs, one copy, several very confident checkouts.",
"mbx[savings]: reflinked {size} into place. copying is for people with disk to spare.",
"mbx[savings]: {size} of outputs exist once and appear everywhere. do not tell the checkouts.",
"mbx[savings]: {size} of target/ is an elaborate illusion. the disk is in on it.",
"mbx[savings]: lent the same {size} to every checkout at once. none of them has checked.",
"mbx[savings]: {size} shared among checkouts that each believe they are an only child.",
"mbx[savings]: {size} on loan to every checkout simultaneously. the paperwork is an inode.",
"mbx[savings]: {size} of outputs materialized by reflink. the copy machine stays off.",
"mbx[savings]: {size} in every checkout, {size} on disk. arithmetic declined to comment.",
],
plain: format!("mbx[savings]: {size} of outputs reflinked rather than copied"),
});
}
if tally.cached_compilations >= MIN_CACHED_COMPILATIONS {
let count = tally.cached_compilations;
eligible.push(Candidate {
cheeky: tellings![
"mbx[savings]: {count} compilations, each compiled once and served warm ever since",
"mbx[savings]: {count} cache hits and counting. rustc thinks it has been busy.",
"mbx[savings]: {count} compilations on file. the box remembers.",
"mbx[savings]: {count} compilations old. not one compiled twice.",
"mbx[savings]: {count} compilations served. the monocle stays on.",
"mbx[savings]: {count} compilations dispensed from stock. inventory immaculate.",
"mbx[savings]: {count} compilations, and the box has forgotten none of them.",
"mbx[savings]: {count} compilations retrieved with a straight face.",
"mbx[savings]: {count} compilations. rustc did each once; mbx did the rest of the showing up.",
"mbx[savings]: {count} compilations served. ask rustc how many it remembers doing.",
],
plain: format!("mbx[savings]: {count} compilations served from cache to date"),
});
}
eligible
}
fn quip_choosing(
tally: &Tally,
facts: &SessionFacts,
style: SavingsStyle,
mut pick: impl FnMut(usize) -> usize,
) -> Option<String> {
if style == SavingsStyle::Off {
return None;
}
let mut eligible = facts_worth_telling(tally, facts);
if eligible.is_empty() {
return None;
}
let index = pick(eligible.len()) % eligible.len();
let candidate = eligible.swap_remove(index);
match style {
SavingsStyle::Quips => {
let mut variants = candidate.cheeky;
let index = pick(variants.len()) % variants.len();
Some(variants.swap_remove(index))
}
SavingsStyle::Plain => Some(candidate.plain),
SavingsStyle::Off => unreachable!("handled above"),
}
}
pub(crate) fn quip(tally: &Tally, facts: &SessionFacts, style: SavingsStyle) -> Option<String> {
use rand::RngExt as _;
quip_choosing(tally, facts, style, |len| rand::rng().random_range(0..len))
}
fn iec(bytes: u64) -> String {
ByteSize::b(bytes).display().iec().to_string()
}
fn nanos(nanoseconds: u64) -> String {
let total = Duration::from_nanos(nanoseconds).as_secs();
if total == 0 {
return format_duration(Duration::from_nanos(nanoseconds));
}
let units = [(86_400, "d"), (3_600, "h"), (60, "m"), (1, "s")];
let mut parts = Vec::new();
let mut rest = total;
for (size, suffix) in units {
let amount = rest / size;
rest %= size;
if amount > 0 {
parts.push(format!("{amount}{suffix}"));
if parts.len() == 2 {
break;
}
}
}
parts.join(" ")
}
fn builds(count: u64) -> String {
if count == 1 {
"1 build".to_string()
} else {
format!("{count} builds")
}
}
fn duration_ns(duration: Duration) -> u64 {
crate::util::duration_ns(duration)
}
pub(crate) fn record_quietly(store: &Path, delta: &Delta) -> Option<Tally> {
match record(store, delta) {
Ok(tally) => Some(tally),
Err(error) => {
log::debug!("savings were not recorded: {error}");
None
}
}
}
pub(crate) fn record_and_describe(
store: &Path,
delta: &Delta,
facts: &SessionFacts,
style: SavingsStyle,
) -> Option<String> {
if delta.is_empty() && facts.hits == 0 {
return None;
}
let tally = record_quietly(store, delta)?;
quip(&tally, facts, style)
}
#[cfg(test)]
#[path = "savings_tests.rs"]
mod tests;