use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
const MAX_STATS_RECORDS: usize = 100_000;
const MAX_LINE_LENGTH: usize = 65_536;
const RETENTION_DAYS: u64 = 90;
use crate::cost::TokenSavings;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunRecord {
pub timestamp: String,
pub date: String,
pub provider: String,
pub images: usize,
pub modified: usize,
#[serde(default)]
pub dropped: usize,
#[serde(default)]
pub svgs_rasterized: usize,
pub bytes_before: usize,
pub bytes_after: usize,
pub token_savings: TokenSavings,
#[serde(default)]
pub duration_ms: u64,
#[serde(default)]
pub action_counts: Vec<(String, usize)>,
}
#[derive(Debug, Clone, Default)]
pub struct GainSummary {
pub total_runs: usize,
pub total_images: usize,
pub total_modified: usize,
pub total_bytes_before: u64,
pub total_bytes_after: u64,
pub total_openai_before: u64,
pub total_openai_after: u64,
pub total_anthropic_before: u64,
pub total_anthropic_after: u64,
pub total_duration_ms: u64,
pub by_provider: Vec<ProviderGain>,
pub by_action: Vec<ActionGain>,
}
#[derive(Debug, Clone)]
pub struct ProviderGain {
pub provider: String,
pub runs: usize,
pub images: usize,
pub tokens_saved: u64,
pub overall_pct: f64,
pub avg_duration_ms: u64,
}
#[derive(Debug, Clone)]
pub struct ActionGain {
pub action: String,
pub count: usize,
}
#[derive(Debug, Clone)]
pub struct DailyGain {
pub date: String,
pub runs: usize,
pub images: usize,
pub openai_saved: u64,
pub anthropic_saved: u64,
}
impl GainSummary {
pub fn openai_saved(&self) -> u64 {
self.total_openai_before
.saturating_sub(self.total_openai_after)
}
pub fn anthropic_saved(&self) -> u64 {
self.total_anthropic_before
.saturating_sub(self.total_anthropic_after)
}
pub fn openai_pct(&self) -> f64 {
if self.total_openai_before == 0 {
return 0.0;
}
(self.openai_saved() as f64 / self.total_openai_before as f64) * 100.0
}
pub fn anthropic_pct(&self) -> f64 {
if self.total_anthropic_before == 0 {
return 0.0;
}
(self.anthropic_saved() as f64 / self.total_anthropic_before as f64) * 100.0
}
pub fn bytes_saved(&self) -> u64 {
self.total_bytes_before
.saturating_sub(self.total_bytes_after)
}
}
pub fn default_stats_path() -> Result<PathBuf> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.context("could not determine home directory")?;
Ok(PathBuf::from(home).join(".shift").join("stats.jsonl"))
}
#[cfg(unix)]
fn acquire_stats_lock(stats_path: &std::path::Path) -> Option<fs::File> {
let lock_path = stats_path.with_extension("lock");
let file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)
.ok()?;
use std::os::unix::io::AsRawFd;
let fd = file.as_raw_fd();
let ret = unsafe { libc::flock(fd, libc::LOCK_EX) };
if ret != 0 {
return None;
}
Some(file)
}
#[cfg(not(unix))]
fn acquire_stats_lock(_stats_path: &std::path::Path) -> Option<fs::File> {
None }
pub fn record_run(record: &RunRecord, path: Option<&PathBuf>) -> Result<()> {
let stats_path = match path {
Some(p) => p.clone(),
None => default_stats_path()?,
};
if let Some(parent) = stats_path.parent() {
fs::create_dir_all(parent).context("failed to create ~/.shift directory")?;
let dir_meta = fs::symlink_metadata(parent)
.with_context(|| format!("failed to stat {}", parent.display()))?;
if dir_meta.file_type().is_symlink() {
anyhow::bail!(
"stats directory {} is a symlink (possible symlink attack)",
parent.display()
);
}
}
let _lock = acquire_stats_lock(&stats_path);
#[cfg(unix)]
let mut file = {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.create(true)
.append(true)
.custom_flags(libc::O_NOFOLLOW)
.open(&stats_path)
.with_context(|| {
format!(
"failed to open stats file: {} (symlinks are rejected)",
stats_path.display()
)
})?
};
#[cfg(not(unix))]
let mut file = {
if stats_path.exists() {
let file_meta = fs::symlink_metadata(&stats_path)
.with_context(|| format!("failed to stat {}", stats_path.display()))?;
if file_meta.file_type().is_symlink() {
anyhow::bail!(
"stats file {} is a symlink (possible symlink attack)",
stats_path.display()
);
}
}
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&stats_path)
.with_context(|| format!("failed to open stats file: {}", stats_path.display()))?
};
let mut line = serde_json::to_string(record).context("failed to serialize run record")?;
line.push('\n');
file.write_all(line.as_bytes())
.context("failed to write to stats file")?;
file.flush().context("failed to flush stats file")?;
drop(file);
if let Ok(meta) = fs::metadata(&stats_path) {
if meta.len() > 50_000 {
if let Err(e) = purge_old_records(&stats_path) {
eprintln!("shift-ai: warning: auto-purge failed: {}", e);
}
}
}
Ok(())
}
pub fn purge_old_records(path: &PathBuf) -> Result<usize> {
let cutoff_date = {
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let cutoff_secs = now_secs.saturating_sub(RETENTION_DAYS * 86400);
let (y, m, d) = days_to_ymd(cutoff_secs / 86400);
format!("{:04}-{:02}-{:02}", y, m, d)
};
let load_result = load_records(Some(path))?;
let total = load_result.records.len();
let kept: Vec<&RunRecord> = load_result
.records
.iter()
.filter(|r| r.date >= cutoff_date)
.collect();
let purged = total - kept.len();
if purged == 0 {
return Ok(0);
}
let parent = path
.parent()
.context("stats file has no parent directory")?;
let mut tmp_file =
tempfile::NamedTempFile::new_in(parent).context("failed to create temp file for purge")?;
for record in &kept {
let mut line = serde_json::to_string(record)?;
line.push('\n');
tmp_file.write_all(line.as_bytes())?;
}
tmp_file.flush()?;
tmp_file
.as_file()
.sync_all()
.context("failed to sync temp file")?;
tmp_file
.persist(path)
.context("failed to rename purged stats file")?;
Ok(purged)
}
pub struct LoadResult {
pub records: Vec<RunRecord>,
pub skipped_lines: usize,
}
pub fn load_records(path: Option<&PathBuf>) -> Result<LoadResult> {
let stats_path = match path {
Some(p) => p.clone(),
None => default_stats_path()?,
};
if !stats_path.exists() {
return Ok(LoadResult {
records: Vec::new(),
skipped_lines: 0,
});
}
let file = fs::File::open(&stats_path)
.with_context(|| format!("failed to open stats file: {}", stats_path.display()))?;
let reader = BufReader::new(file);
let mut records = Vec::new();
let mut skipped_lines = 0;
for (i, line) in reader.lines().enumerate() {
let line = line.with_context(|| format!("failed to read line {} of stats file", i + 1))?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.len() > MAX_LINE_LENGTH {
eprintln!(
"shift-ai: warning: skipping oversized stats line {} ({} bytes)",
i + 1,
trimmed.len()
);
skipped_lines += 1;
continue;
}
match serde_json::from_str::<RunRecord>(trimmed) {
Ok(record) => records.push(record),
Err(e) => {
eprintln!(
"shift-ai: warning: skipping malformed stats line {}: {}",
i + 1,
e
);
skipped_lines += 1;
}
}
if records.len() >= MAX_STATS_RECORDS {
eprintln!(
"shift-ai: warning: stats file has >{} entries, loading only the first {}",
MAX_STATS_RECORDS, MAX_STATS_RECORDS
);
break;
}
}
Ok(LoadResult {
records,
skipped_lines,
})
}
pub fn summarize(records: &[RunRecord]) -> GainSummary {
use std::collections::BTreeMap;
let mut s = GainSummary::default();
let mut providers: BTreeMap<String, (usize, usize, u64, u64, u64)> = BTreeMap::new();
let mut actions: BTreeMap<String, usize> = BTreeMap::new();
for r in records {
s.total_runs += 1;
s.total_images += r.images;
s.total_modified += r.modified;
s.total_bytes_before += r.bytes_before as u64;
s.total_bytes_after += r.bytes_after as u64;
s.total_openai_before += r.token_savings.openai_before;
s.total_openai_after += r.token_savings.openai_after;
s.total_anthropic_before += r.token_savings.anthropic_before;
s.total_anthropic_after += r.token_savings.anthropic_after;
s.total_duration_ms += r.duration_ms;
let entry = providers.entry(r.provider.clone()).or_default();
entry.0 += 1; entry.1 += r.images; let provider_lower = r.provider.to_ascii_lowercase();
let (before, after) = if provider_lower == "anthropic" {
(
r.token_savings.anthropic_before,
r.token_savings.anthropic_after,
)
} else {
(r.token_savings.openai_before, r.token_savings.openai_after)
};
entry.2 += before;
entry.3 += after;
entry.4 += r.duration_ms;
for (action, count) in &r.action_counts {
*actions.entry(action.clone()).or_default() += count;
}
}
let mut by_provider: Vec<ProviderGain> = providers
.into_iter()
.map(|(name, (runs, images, before, after, dur))| {
let saved = before.saturating_sub(after);
let overall_pct = if before > 0 {
(saved as f64 / before as f64) * 100.0
} else {
0.0
};
let avg_dur = if runs > 0 { dur / runs as u64 } else { 0 };
ProviderGain {
provider: name,
runs,
images,
tokens_saved: saved,
overall_pct,
avg_duration_ms: avg_dur,
}
})
.collect();
by_provider.sort_by_key(|b| std::cmp::Reverse(b.tokens_saved));
s.by_provider = by_provider;
let mut by_action: Vec<ActionGain> = actions
.into_iter()
.map(|(action, count)| ActionGain { action, count })
.collect();
by_action.sort_by_key(|b| std::cmp::Reverse(b.count));
s.by_action = by_action;
s
}
pub fn daily_breakdown(records: &[RunRecord]) -> Vec<DailyGain> {
use std::collections::BTreeMap;
let mut days: BTreeMap<String, DailyGain> = BTreeMap::new();
for r in records {
let entry = days.entry(r.date.clone()).or_insert_with(|| DailyGain {
date: r.date.clone(),
runs: 0,
images: 0,
openai_saved: 0,
anthropic_saved: 0,
});
entry.runs += 1;
entry.images += r.images;
entry.openai_saved += r
.token_savings
.openai_before
.saturating_sub(r.token_savings.openai_after);
entry.anthropic_saved += r
.token_savings
.anthropic_before
.saturating_sub(r.token_savings.anthropic_after);
}
days.into_values().collect()
}
pub fn record_from_report(
report: &crate::report::Report,
provider: &str,
duration_ms: u64,
) -> RunRecord {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let secs_per_day = 86400;
let days_since_epoch = now / secs_per_day;
let secs_today = now % secs_per_day;
let hours = secs_today / 3600;
let minutes = (secs_today % 3600) / 60;
let seconds = secs_today % 60;
let (year, month, day) = days_to_ymd(days_since_epoch);
let timestamp = format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hours, minutes, seconds
);
let date = format!("{:04}-{:02}-{:02}", year, month, day);
let mut action_map = std::collections::BTreeMap::new();
for a in &report.actions {
*action_map.entry(a.action.clone()).or_insert(0usize) += 1;
}
let action_counts: Vec<(String, usize)> = action_map.into_iter().collect();
RunRecord {
timestamp,
date,
provider: provider.to_string(),
images: report.images_found,
modified: report.images_modified,
dropped: report.images_dropped,
svgs_rasterized: report.svgs_rasterized,
bytes_before: report.original_size,
bytes_after: report.transformed_size,
token_savings: report.token_savings.clone(),
duration_ms,
action_counts,
}
}
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
let z = days + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cost::TokenSavings;
use tempfile::NamedTempFile;
fn make_record(date: &str, openai_before: u64, openai_after: u64) -> RunRecord {
RunRecord {
timestamp: format!("{}T12:00:00Z", date),
date: date.to_string(),
provider: "openai".to_string(),
images: 3,
modified: 2,
dropped: 0,
svgs_rasterized: 0,
bytes_before: 5_000_000,
bytes_after: 1_000_000,
token_savings: TokenSavings {
openai_before,
openai_after,
anthropic_before: 3000,
anthropic_after: 1000,
},
duration_ms: 500,
action_counts: vec![("resize".to_string(), 2)],
}
}
#[test]
fn test_record_and_load_roundtrip() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let r1 = make_record("2026-04-20", 1000, 300);
let r2 = make_record("2026-04-21", 2000, 500);
record_run(&r1, Some(&path)).unwrap();
record_run(&r2, Some(&path)).unwrap();
let result = load_records(Some(&path)).unwrap();
assert_eq!(result.records.len(), 2);
assert_eq!(result.skipped_lines, 0);
assert_eq!(result.records[0].date, "2026-04-20");
assert_eq!(result.records[1].date, "2026-04-21");
}
#[test]
fn test_load_empty_file() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let result = load_records(Some(&path)).unwrap();
assert!(result.records.is_empty());
assert_eq!(result.skipped_lines, 0);
}
#[test]
fn test_load_nonexistent_file() {
let path = PathBuf::from("/tmp/shift-test-nonexistent-stats.jsonl");
let result = load_records(Some(&path)).unwrap();
assert!(result.records.is_empty());
assert_eq!(result.skipped_lines, 0);
}
#[test]
fn test_summarize() {
let records = vec![
make_record("2026-04-20", 1000, 300),
make_record("2026-04-21", 2000, 500),
];
let summary = summarize(&records);
assert_eq!(summary.total_runs, 2);
assert_eq!(summary.total_images, 6);
assert_eq!(summary.total_modified, 4);
assert_eq!(summary.total_openai_before, 3000);
assert_eq!(summary.total_openai_after, 800);
assert_eq!(summary.openai_saved(), 2200);
}
#[test]
fn test_daily_breakdown() {
let records = vec![
make_record("2026-04-20", 1000, 300),
make_record("2026-04-20", 500, 200),
make_record("2026-04-21", 2000, 500),
];
let daily = daily_breakdown(&records);
assert_eq!(daily.len(), 2);
assert_eq!(daily[0].date, "2026-04-20");
assert_eq!(daily[0].runs, 2);
assert_eq!(daily[0].openai_saved, 1000); assert_eq!(daily[1].date, "2026-04-21");
assert_eq!(daily[1].runs, 1);
}
#[test]
fn test_summary_percentages() {
let summary = GainSummary {
total_openai_before: 10000,
total_openai_after: 3000,
total_anthropic_before: 5000,
total_anthropic_after: 1000,
..Default::default()
};
assert!((summary.openai_pct() - 70.0).abs() < 0.1);
assert!((summary.anthropic_pct() - 80.0).abs() < 0.1);
}
#[test]
fn test_summary_zero_division() {
let summary = GainSummary::default();
assert_eq!(summary.openai_pct(), 0.0);
assert_eq!(summary.anthropic_pct(), 0.0);
}
#[test]
fn test_malformed_lines_skipped() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let r = make_record("2026-04-20", 1000, 300);
record_run(&r, Some(&path)).unwrap();
let mut f = fs::OpenOptions::new().append(true).open(&path).unwrap();
writeln!(f, "not json at all").unwrap();
writeln!(f, "{{\"partial\": true}}").unwrap();
record_run(&r, Some(&path)).unwrap();
let result = load_records(Some(&path)).unwrap();
assert_eq!(result.records.len(), 2); assert_eq!(result.skipped_lines, 2); }
#[test]
fn test_record_from_report() {
let mut report = crate::report::Report::new();
report.images_found = 3;
report.images_modified = 2;
report.original_size = 5_000_000;
report.transformed_size = 1_000_000;
report.token_savings = TokenSavings {
openai_before: 2000,
openai_after: 500,
anthropic_before: 3000,
anthropic_after: 800,
};
let record = record_from_report(&report, "openai", 1234);
assert_eq!(record.provider, "openai");
assert_eq!(record.images, 3);
assert_eq!(record.modified, 2);
assert_eq!(record.duration_ms, 1234);
assert!(!record.timestamp.is_empty());
assert!(!record.date.is_empty());
}
#[test]
fn test_days_to_ymd() {
let (y, m, d) = days_to_ymd(0);
assert_eq!((y, m, d), (1970, 1, 1));
let (y, m, d) = days_to_ymd(11016);
assert_eq!((y, m, d), (2000, 2, 29));
let (y, m, d) = days_to_ymd(11017);
assert_eq!((y, m, d), (2000, 3, 1));
let (y, m, d) = days_to_ymd(47540);
assert_eq!((y, m, d), (2100, 2, 28));
let (y, m, d) = days_to_ymd(47541);
assert_eq!((y, m, d), (2100, 3, 1));
let (y, m, d) = days_to_ymd(20453);
assert_eq!((y, m, d), (2025, 12, 31));
let (y, m, d) = days_to_ymd(20454);
assert_eq!((y, m, d), (2026, 1, 1));
}
#[cfg(unix)]
#[test]
fn test_symlink_directory_rejected() {
use std::os::unix::fs as unix_fs;
let real_dir = tempfile::tempdir().unwrap();
let symlink_dir = tempfile::tempdir().unwrap();
let symlink_path = symlink_dir.path().join("symlinked-shift");
unix_fs::symlink(real_dir.path(), &symlink_path).unwrap();
let stats_file = symlink_path.join("stats.jsonl");
let r = make_record("2026-04-22", 100, 50);
let result = record_run(&r, Some(&stats_file));
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("symlink"),
"expected symlink error, got: {}",
err_msg
);
}
#[cfg(unix)]
#[test]
fn test_symlink_file_rejected() {
use std::os::unix::fs as unix_fs;
let tmp_dir = tempfile::tempdir().unwrap();
let real_file = tmp_dir.path().join("real-stats.jsonl");
let symlink_file = tmp_dir.path().join("stats.jsonl");
fs::write(&real_file, "").unwrap();
unix_fs::symlink(&real_file, &symlink_file).unwrap();
let r = make_record("2026-04-22", 100, 50);
let result = record_run(&r, Some(&symlink_file));
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("symlink"),
"expected symlink error, got: {}",
err_msg
);
}
#[test]
fn test_skipped_lines_counted() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let r = make_record("2026-04-22", 500, 200);
record_run(&r, Some(&path)).unwrap();
let mut f = fs::OpenOptions::new().append(true).open(&path).unwrap();
writeln!(f, "garbage1").unwrap();
writeln!(f, "garbage2").unwrap();
writeln!(f, "garbage3").unwrap();
record_run(&r, Some(&path)).unwrap();
let result = load_records(Some(&path)).unwrap();
assert_eq!(result.records.len(), 2);
assert_eq!(result.skipped_lines, 3);
}
fn make_anthropic_record(date: &str, anthropic_before: u64, anthropic_after: u64) -> RunRecord {
RunRecord {
timestamp: format!("{}T12:00:00Z", date),
date: date.to_string(),
provider: "anthropic".to_string(),
images: 2,
modified: 1,
dropped: 0,
svgs_rasterized: 0,
bytes_before: 3_000_000,
bytes_after: 800_000,
token_savings: TokenSavings {
openai_before: 500,
openai_after: 200,
anthropic_before,
anthropic_after,
},
duration_ms: 300,
action_counts: vec![("recompress".to_string(), 1)],
}
}
fn make_record_with_actions(date: &str, actions: Vec<(String, usize)>) -> RunRecord {
RunRecord {
action_counts: actions,
..make_record(date, 1000, 300)
}
}
#[test]
fn test_purge_removes_old_records() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let old = make_record("2020-01-01", 1000, 300);
record_run(&old, Some(&path)).unwrap();
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let (y, m, d) = days_to_ymd(now_secs / 86400);
let today = format!("{:04}-{:02}-{:02}", y, m, d);
let recent = make_record(&today, 2000, 500);
record_run(&recent, Some(&path)).unwrap();
let purged = purge_old_records(&path).unwrap();
assert_eq!(purged, 1);
let result = load_records(Some(&path)).unwrap();
assert_eq!(result.records.len(), 1);
assert_eq!(result.records[0].date, today);
}
#[test]
fn test_purge_no_op_when_nothing_to_purge() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let (y, m, d) = days_to_ymd(now_secs / 86400);
let today = format!("{:04}-{:02}-{:02}", y, m, d);
let r = make_record(&today, 1000, 300);
record_run(&r, Some(&path)).unwrap();
let purged = purge_old_records(&path).unwrap();
assert_eq!(purged, 0);
let result = load_records(Some(&path)).unwrap();
assert_eq!(result.records.len(), 1);
}
#[test]
fn test_purge_all_records_expired() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let old1 = make_record("2019-01-01", 1000, 300);
let old2 = make_record("2019-06-15", 2000, 500);
record_run(&old1, Some(&path)).unwrap();
record_run(&old2, Some(&path)).unwrap();
let purged = purge_old_records(&path).unwrap();
assert_eq!(purged, 2);
let result = load_records(Some(&path)).unwrap();
assert_eq!(result.records.len(), 0);
}
#[test]
fn test_purge_preserves_record_data() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let (y, m, d) = days_to_ymd(now_secs / 86400);
let today = format!("{:04}-{:02}-{:02}", y, m, d);
let r = RunRecord {
provider: "anthropic".to_string(),
images: 7,
modified: 5,
duration_ms: 1234,
action_counts: vec![("resize".to_string(), 3), ("convert".to_string(), 2)],
..make_record(&today, 5000, 1500)
};
let old = make_record("2020-01-01", 100, 50);
record_run(&old, Some(&path)).unwrap();
record_run(&r, Some(&path)).unwrap();
let purged = purge_old_records(&path).unwrap();
assert_eq!(purged, 1);
let result = load_records(Some(&path)).unwrap();
assert_eq!(result.records.len(), 1);
let kept = &result.records[0];
assert_eq!(kept.provider, "anthropic");
assert_eq!(kept.images, 7);
assert_eq!(kept.modified, 5);
assert_eq!(kept.duration_ms, 1234);
assert_eq!(kept.action_counts.len(), 2);
}
#[test]
fn test_summarize_by_provider() {
let records = vec![
make_record("2026-04-20", 1000, 300), make_record("2026-04-21", 2000, 500), make_anthropic_record("2026-04-20", 4000, 1000), ];
let summary = summarize(&records);
assert_eq!(summary.by_provider.len(), 2);
assert_eq!(summary.by_provider[0].provider, "anthropic");
assert_eq!(summary.by_provider[0].tokens_saved, 3000);
assert_eq!(summary.by_provider[0].runs, 1);
assert_eq!(summary.by_provider[0].images, 2);
assert!((summary.by_provider[0].overall_pct - 75.0).abs() < 0.1);
assert_eq!(summary.by_provider[1].provider, "openai");
assert_eq!(summary.by_provider[1].tokens_saved, 2200);
assert_eq!(summary.by_provider[1].runs, 2);
assert_eq!(summary.by_provider[1].images, 6);
}
#[test]
fn test_summarize_single_provider() {
let records = vec![make_record("2026-04-20", 1000, 300)];
let summary = summarize(&records);
assert_eq!(summary.by_provider.len(), 1);
assert_eq!(summary.by_provider[0].provider, "openai");
assert_eq!(summary.by_provider[0].tokens_saved, 700);
}
#[test]
fn test_summarize_provider_duration() {
let records = vec![
make_record("2026-04-20", 1000, 300), make_record("2026-04-21", 2000, 500), ];
let summary = summarize(&records);
assert_eq!(summary.by_provider[0].avg_duration_ms, 500); assert_eq!(summary.total_duration_ms, 1000);
}
#[test]
fn test_summarize_by_action() {
let records = vec![
make_record_with_actions(
"2026-04-20",
vec![("resize".to_string(), 3), ("convert".to_string(), 1)],
),
make_record_with_actions(
"2026-04-21",
vec![("resize".to_string(), 2), ("recompress".to_string(), 4)],
),
];
let summary = summarize(&records);
assert_eq!(summary.by_action.len(), 3);
assert_eq!(summary.by_action[0].action, "resize");
assert_eq!(summary.by_action[0].count, 5);
assert_eq!(summary.by_action[1].action, "recompress");
assert_eq!(summary.by_action[1].count, 4);
assert_eq!(summary.by_action[2].action, "convert");
assert_eq!(summary.by_action[2].count, 1);
}
#[test]
fn test_summarize_empty_actions() {
let mut r = make_record("2026-04-20", 1000, 300);
r.action_counts = vec![];
let summary = summarize(&[r]);
assert!(summary.by_action.is_empty());
}
#[test]
fn test_summarize_empty_records() {
let summary = summarize(&[]);
assert_eq!(summary.total_runs, 0);
assert!(summary.by_provider.is_empty());
assert!(summary.by_action.is_empty());
}
}