use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use colored::Colorize;
use crate::config::{PrunedDir, Registry};
use crate::constants::PRUNE_LOG_STARTS_AT;
use crate::history::{self, Pass};
use crate::output;
const PASSES_SHOWN: usize = 20;
const DETAIL_DIRS_SHOWN: usize = 200;
pub struct HistoryArgs {
pub pass: Option<usize>,
pub limit: Option<usize>,
pub all: bool,
pub json: bool,
pub export: Option<Option<PathBuf>>,
pub scores: bool,
}
pub fn run(args: &HistoryArgs) -> Result<()> {
let registry = Registry::load()?;
let passes = history::merged(history::load()?, ®istry);
if args.scores {
return print_scores(&passes, args.json);
}
if let Some(n) = args.pass {
check_pass_number(&passes, n)?;
}
if let Some(destination) = &args.export {
return export(&passes, args.pass, destination.as_deref());
}
if args.json {
return crate::json::emit(&crate::json::history_document(&passes, args.pass));
}
match args.pass {
Some(n) => print_one_pass(&passes, n),
None => {
print_pass_list(&passes, args);
Ok(())
}
}
}
type SingleCleanup = (u64, DateTime<Utc>, Option<String>);
type TotalRun = (u64, DateTime<Utc>);
fn collect_scores(passes: &[Pass]) -> (Vec<SingleCleanup>, Vec<TotalRun>) {
let mut single_cleanups: Vec<(u64, DateTime<Utc>, Option<String>)> = Vec::new();
for pass in passes {
match pass {
Pass::Detailed(record) => {
let mut per_repo: std::collections::HashMap<PathBuf, u64> =
std::collections::HashMap::new();
for d in &record.dirs {
*per_repo.entry(d.repo_path.clone()).or_insert(0) += d.size_freed;
}
for (repo, bytes) in per_repo {
if bytes > 0 {
let name = repo.file_name().map(|f| f.to_string_lossy().to_string());
single_cleanups.push((bytes, record.at, name));
}
}
}
Pass::Summary {
at,
bytes_freed,
repos_touched,
dirs,
..
} => {
if let Some(dirs) = dirs {
let mut per_repo: std::collections::HashMap<PathBuf, u64> =
std::collections::HashMap::new();
for d in dirs {
*per_repo.entry(d.repo_path.clone()).or_insert(0) += d.size_freed;
}
for (repo, bytes) in per_repo {
if bytes > 0 {
let name = repo.file_name().map(|f| f.to_string_lossy().to_string());
single_cleanups.push((bytes, *at, name));
}
}
} else if *repos_touched == 1 && *bytes_freed > 0 {
single_cleanups.push((*bytes_freed, *at, None));
}
}
}
}
single_cleanups.sort_by_key(|entry| std::cmp::Reverse(entry.0));
let mut total_runs: Vec<(u64, DateTime<Utc>)> = passes
.iter()
.filter(|p| p.bytes_freed() > 0)
.map(|p| (p.bytes_freed(), p.at()))
.collect();
total_runs.sort_by_key(|entry| std::cmp::Reverse(entry.0));
(single_cleanups, total_runs)
}
fn scores_document(
single_cleanups: &[(u64, DateTime<Utc>, Option<String>)],
total_runs: &[(u64, DateTime<Utc>)],
) -> serde_json::Value {
use serde_json::json;
let medals = ["Gold", "Silver", "Bronze"];
let medals_emojis = ["🥇", "🥈", "🥉"];
let single_cleanup_json: Vec<serde_json::Value> = single_cleanups
.iter()
.take(3)
.enumerate()
.map(|(i, (bytes, date, repo))| {
json!({
"rank": i + 1,
"medal": medals[i],
"emoji": medals_emojis[i],
"bytes": bytes,
"formatted_bytes": output::format_bytes(*bytes),
"date": date.format("%Y-%m-%d").to_string(),
"repo": repo,
})
})
.collect();
let total_run_json: Vec<serde_json::Value> = total_runs
.iter()
.take(3)
.enumerate()
.map(|(i, (bytes, date))| {
json!({
"rank": i + 1,
"medal": medals[i],
"emoji": medals_emojis[i],
"bytes": bytes,
"formatted_bytes": output::format_bytes(*bytes),
"date": date.format("%Y-%m-%d").to_string(),
})
})
.collect();
json!({
"schema": crate::json::SCHEMA_VERSION,
"version": crate::constants::VERSION,
"command": "history --scores",
"single_cleanup": single_cleanup_json,
"total_run": total_run_json,
})
}
pub fn print_scores(passes: &[Pass], json: bool) -> Result<()> {
let (single_cleanups, total_runs) = collect_scores(passes);
if json {
return crate::json::emit(&scores_document(&single_cleanups, &total_runs));
}
if single_cleanups.is_empty() && total_runs.is_empty() {
output::print_header("High Scores Leaderboard");
output::print_info(
"No prune passes recorded yet. Run `devp run` to earn your first cleanup badge!",
);
return Ok(());
}
println!();
println!(" {}", "──── ★ SINGLE CLEANUP ★ ────".cyan().bold());
if single_cleanups.is_empty() {
println!(" {}", "No single repository records yet".dimmed());
} else {
for (i, (bytes, date, repo)) in single_cleanups.iter().take(3).enumerate() {
match i {
0 => println!(" {}", "🥇 Gold".yellow().bold()),
1 => println!(" {}", "🥈 Silver".white().bold()),
_ => println!(" {}", "🥉 Bronze".bright_yellow().bold()),
}
let repo_tag = repo
.as_ref()
.map(|r| format!(" ({})", r).cyan().to_string())
.unwrap_or_default();
println!(
" {} · {}{}",
output::format_bytes(*bytes).green().bold(),
date.format("%Y-%m-%d").to_string().dimmed(),
repo_tag
);
println!(" {}", "────────────────────────────".dimmed());
}
}
println!();
println!(" {}", "──── ★ TOTAL RUN ★ ────".cyan().bold());
if total_runs.is_empty() {
println!(" {}", "No total run records yet".dimmed());
} else {
for (i, (bytes, date)) in total_runs.iter().take(3).enumerate() {
match i {
0 => println!(" {}", "🥇 Gold".yellow().bold()),
1 => println!(" {}", "🥈 Silver".white().bold()),
_ => println!(" {}", "🥉 Bronze".bright_yellow().bold()),
}
println!(
" {} · {}",
output::format_bytes(*bytes).green().bold(),
date.format("%Y-%m-%d").to_string().dimmed()
);
println!(" {}", "────────────────────────────".dimmed());
}
}
println!();
Ok(())
}
fn check_pass_number(passes: &[Pass], number: usize) -> Result<()> {
if number == 0 || number > passes.len() {
return Err(anyhow::Error::new(crate::UsageError(format!(
"There is no pass #{number}. {} recorded; `devp history` lists them, newest first.",
passes.len()
))));
}
Ok(())
}
fn print_pass_list(passes: &[Pass], args: &HistoryArgs) {
output::print_header("Prune passes");
if passes.is_empty() {
output::print_info(
"Nothing recorded yet — `devp run --dry-run` shows what a pass would do.",
);
return;
}
let shown = if args.all {
passes.len()
} else {
args.limit.unwrap_or(PASSES_SHOWN).min(passes.len())
};
for (index, pass) in passes.iter().take(shown).enumerate() {
let number = index + 1;
let (started_by, note) = match pass {
Pass::Detailed(record) => (record.trigger.label().to_string(), String::new()),
Pass::Summary { .. } => ("—".to_string(), " (totals only)".to_string()),
};
println!(
" {} {} {} {:<10} {} {}, {} {}{}",
format!("#{number}").dimmed(),
pass.at().format("%Y-%m-%d %H:%M"),
format!("{:>10}", output::format_bytes(pass.bytes_freed())).green(),
started_by,
pass.dirs_removed(),
output::plural(pass.dirs_removed(), "directory", "directories"),
pass.repos_touched(),
output::plural(pass.repos_touched(), "repository", "repositories"),
note.dimmed(),
);
}
if shown < passes.len() {
output::print_info(&format!(
"{} passes recorded; showing the most recent {shown}. `--all` for every one.",
passes.len()
));
}
if passes.iter().any(|p| matches!(p, Pass::Summary { .. })) {
output::print_dimmed(&format!(
" Passes marked \"totals only\" ran before {PRUNE_LOG_STARTS_AT}, which is where \
the per-directory log starts."
));
}
output::print_info("What one pass deleted: devp history --pass 1");
output::print_info("All of it, as a file: devp history --export");
}
fn print_one_pass(passes: &[Pass], number: usize) -> Result<()> {
check_pass_number(passes, number)?;
let pass = &passes[number - 1];
output::print_header(&format!("Pass #{number}"));
output::print_info(&format!(
"When {} ({})",
pass.at().format("%Y-%m-%d %H:%M UTC"),
describe_age(pass.at()),
));
match pass {
Pass::Detailed(record) => {
output::print_info(&format!("Started by {}", record.trigger.label()));
output::print_info(&format!("Command {}", record.command_line()));
if !record.version.is_empty() {
output::print_info(&format!("Version dev-prune {}", record.version));
}
}
Pass::Summary { .. } => {
output::print_info(&format!(
"Started by not recorded — this pass predates {PRUNE_LOG_STARTS_AT}"
));
}
}
output::print_info(&format!(
"Freed {} from {} {} in {} {}",
output::format_bytes_styled(pass.bytes_freed()),
pass.dirs_removed(),
output::plural(pass.dirs_removed(), "directory", "directories"),
pass.repos_touched(),
output::plural(pass.repos_touched(), "repository", "repositories"),
));
let Some(dirs) = pass.dirs() else {
output::print_header("Directories");
output::print_info(&format!(
"Not recorded. Only the totals above were kept before {PRUNE_LOG_STARTS_AT}; every \
pass from that release on carries its full list."
));
return Ok(());
};
print_directories(dirs, number);
Ok(())
}
fn print_directories(dirs: &[PrunedDir], number: usize) {
use std::io::IsTerminal;
output::print_header("Directories");
let mut grouped: Vec<(&PathBuf, Vec<&PrunedDir>)> = Vec::new();
for dir in dirs {
match grouped.iter_mut().find(|(repo, _)| *repo == &dir.repo_path) {
Some((_, entries)) => entries.push(dir),
None => grouped.push((&dir.repo_path, vec![dir])),
}
}
grouped.sort_by_key(|(_, entries)| {
std::cmp::Reverse(entries.iter().map(|d| d.size_freed).sum::<u64>())
});
let budget = if std::io::stdout().is_terminal() {
DETAIL_DIRS_SHOWN
} else {
usize::MAX
};
let mut printed = 0usize;
for (repo, entries) in &grouped {
if printed >= budget {
break;
}
println!(" {}", output::styled_path(repo));
for dir in entries {
if printed >= budget {
break;
}
println!(
" {} {} {}",
format!("{:>10}", output::format_bytes(dir.size_freed)).green(),
output::pad_display(&dir.bloat_dir, 32),
output::styled_adapter(&dir.adapter),
);
printed += 1;
}
}
if printed < dirs.len() {
output::print_info(&format!(
"{} more not shown. `devp history --pass {number} --json` or `devp history --export` \
has all {}.",
dirs.len() - printed,
dirs.len(),
));
}
}
fn export(passes: &[Pass], only: Option<usize>, destination: Option<&Path>) -> Result<()> {
let path = resolve_export_path(destination)?;
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
let document = crate::json::history_document(passes, only);
let contents =
serde_json::to_string_pretty(&document).context("Failed to serialize history")?;
std::fs::write(&path, &contents)
.with_context(|| format!("Failed to write {}", path.display()))?;
let written = if only.is_some() { 1 } else { passes.len() };
output::print_success(&format!(
"{written} {} written to {}",
output::plural(written, "pass", "passes"),
output::clean_path(&path),
));
Ok(())
}
pub fn resolve_export_path(destination: Option<&Path>) -> Result<PathBuf> {
let name = format!("dev-prune-history-{}.json", Utc::now().format("%Y-%m-%d"));
match destination {
Some(path) if path.is_dir() => Ok(path.join(name)),
Some(path) => Ok(path.to_path_buf()),
None => {
let base = dirs::document_dir()
.or_else(dirs::home_dir)
.context("Could not find a documents or home directory to export into. Pass a path: `devp history --export <FILE>`")?;
Ok(base.join(name))
}
}
}
fn describe_age(at: DateTime<Utc>) -> String {
let elapsed = Utc::now().signed_duration_since(at);
let days = elapsed.num_days();
if days >= 1 {
return format!(
"{days} {} ago",
output::plural(days as usize, "day", "days")
);
}
let hours = elapsed.num_hours();
if hours >= 1 {
return format!(
"{hours} {} ago",
output::plural(hours as usize, "hour", "hours")
);
}
let minutes = elapsed.num_minutes().max(0);
format!(
"{minutes} {} ago",
output::plural(minutes as usize, "minute", "minutes")
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::history::{PassRecord, Trigger};
use tempfile::TempDir;
fn pass(at: DateTime<Utc>) -> Pass {
Pass::Detailed(PassRecord {
at,
trigger: Trigger::Manual,
argv: vec!["run".to_string()],
version: "1.17.0".to_string(),
dirs: vec![PrunedDir {
repo_path: PathBuf::from("/a"),
bloat_dir: "node_modules".to_string(),
adapter: "npm".to_string(),
size_freed: 100,
runtime: None,
}],
})
}
#[test]
fn a_pass_number_nobody_has_is_a_usage_error_not_an_empty_report() {
let passes = vec![pass(Utc::now())];
for n in [0usize, 2, 99] {
let err = print_one_pass(&passes, n).unwrap_err();
assert!(
err.downcast_ref::<crate::UsageError>().is_some(),
"--pass {n} should exit 2"
);
}
}
#[test]
fn asking_for_a_pass_on_an_empty_log_is_a_usage_error() {
let err = print_one_pass(&[], 1).unwrap_err();
assert!(err.downcast_ref::<crate::UsageError>().is_some());
}
#[test]
fn exporting_into_a_directory_keeps_the_generated_filename() {
let tmp = TempDir::new().unwrap();
let path = resolve_export_path(Some(tmp.path())).unwrap();
assert_eq!(path.parent().unwrap(), tmp.path());
assert!(
path.file_name()
.unwrap()
.to_string_lossy()
.starts_with("dev-prune-history-")
);
}
#[test]
fn exporting_to_a_named_file_uses_exactly_that_name() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("mine.json");
assert_eq!(resolve_export_path(Some(&target)).unwrap(), target);
}
#[test]
fn an_export_writes_a_document_that_parses() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("nested").join("history.json");
export(&[pass(Utc::now())], None, Some(&target)).unwrap();
let raw = std::fs::read_to_string(&target).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed["command"], "history");
assert_eq!(parsed["passes"].as_array().unwrap().len(), 1);
}
#[test]
fn an_empty_log_still_prints_a_scores_report() {
assert!(print_scores(&[], false).is_ok());
assert!(print_scores(&[], true).is_ok());
let doc = scores_document(&[], &[]);
assert_eq!(doc["command"], "history --scores");
assert_eq!(doc["single_cleanup"].as_array().unwrap().len(), 0);
assert_eq!(doc["total_run"].as_array().unwrap().len(), 0);
}
#[test]
fn scores_rank_the_biggest_cleanup_and_the_biggest_run_first() {
let p1 = Pass::Detailed(PassRecord {
at: Utc::now(),
trigger: crate::history::Trigger::Manual,
argv: vec![],
version: "1.23.0".to_string(),
dirs: vec![
PrunedDir {
repo_path: PathBuf::from("/repo1"),
bloat_dir: "node_modules".to_string(),
adapter: "npm".to_string(),
size_freed: 1000,
runtime: None,
},
PrunedDir {
repo_path: PathBuf::from("/repo2"),
bloat_dir: "target".to_string(),
adapter: "cargo".to_string(),
size_freed: 5000,
runtime: None,
},
],
});
let p2 = Pass::Summary {
at: Utc::now(),
bytes_freed: 10000,
dirs_removed: 1,
repos_touched: 1,
dirs: None,
};
let (singles, totals) = collect_scores(&[p1, p2]);
let single_bytes: Vec<u64> = singles.iter().map(|s| s.0).collect();
assert_eq!(single_bytes, vec![10000, 5000, 1000]);
assert_eq!(singles[1].2.as_deref(), Some("repo2"));
assert_eq!(singles[0].2, None, "a totals-only pass names no repository");
let total_bytes: Vec<u64> = totals.iter().map(|t| t.0).collect();
assert_eq!(total_bytes, vec![10000, 6000]);
let doc = scores_document(&singles, &totals);
assert_eq!(doc["command"], "history --scores");
assert_eq!(doc["schema"], crate::json::SCHEMA_VERSION);
assert_eq!(doc["single_cleanup"][0]["rank"], 1);
assert_eq!(doc["single_cleanup"][0]["bytes"], 10000);
assert_eq!(doc["single_cleanup"][0]["medal"], "Gold");
assert_eq!(doc["total_run"][1]["bytes"], 6000);
}
}