use std::io::{self, Write};
use anyhow::Result;
use crate::{args::StorageSubcmd, storage};
pub fn run(cmd: StorageSubcmd) -> Result<()> {
match cmd {
StorageSubcmd::Status => run_status(),
StorageSubcmd::Prune { yes } => run_prune(yes),
StorageSubcmd::Clear => run_clear(),
StorageSubcmd::Config(arg) => run_config(arg.as_deref()),
}
}
fn run_status() -> Result<()> {
let s = storage::status()?;
let meta = storage::load_meta()?;
let cache_dir = storage::cache_dir()?;
println!("\n {}/", cache_dir.display());
println!(
" ├── gh_stats.json {:>4} entries · {:>6} · last write: –",
s.gh_cache_entries,
human_bytes(s.gh_cache_bytes),
);
println!(
" └── snapshots/ {:>4} files · {:>6}",
s.languages.iter().map(|l| l.count).sum::<usize>(),
human_bytes(s.languages.iter().map(|l| l.total_bytes).sum()),
);
for ls in &s.languages {
let oldest = ls.oldest.as_deref().unwrap_or("–");
println!(
" ├── {:<8} {:>3} files · {:>6} · oldest: {}",
ls.lang.label(),
ls.count,
human_bytes(ls.total_bytes),
oldest,
);
}
println!();
println!(" retention policy : {} weeks", s.config.keep_weeks);
if let Some(p) = &meta.last_prune {
println!(" last prune : {}", &p[..10]);
} else {
println!(" last prune : never");
}
println!(" total : {}", human_bytes(s.total_bytes));
println!();
Ok(())
}
fn run_prune(yes: bool) -> Result<()> {
let meta = storage::load_meta()?;
let weeks = meta.config.keep_weeks;
if weeks == 0 {
println!("Snapshots are disabled (keep_weeks=0). Nothing to prune.");
return Ok(());
}
let cutoff = chrono::Local::now().date_naive() - chrono::Duration::weeks(weeks as i64);
use std::fs;
let snaps_dir = storage::cache_dir()?.join("snapshots");
let mut to_remove: Vec<(std::path::PathBuf, u64)> = vec![];
if snaps_dir.exists() {
for entry in fs::read_dir(&snaps_dir)? {
let path = entry?.path();
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let date_part = name.split_once('_').map(|x| x.1).unwrap_or("");
if let Ok(date) = chrono::NaiveDate::parse_from_str(date_part, "%Y%m%d") {
if date < cutoff {
let bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
to_remove.push((path, bytes));
}
}
}
}
to_remove.sort_by_key(|(p, _)| p.clone());
if to_remove.is_empty() {
println!("Nothing to prune (all snapshots within {weeks}-week retention window).");
return Ok(());
}
println!("\n Files to remove (older than {weeks} weeks):");
let total: u64 = to_remove.iter().map(|(_, b)| b).sum();
for (path, bytes) in &to_remove {
println!(
" {} {}",
path.file_name().and_then(|n| n.to_str()).unwrap_or("?"),
human_bytes(*bytes),
);
}
println!(
"\n {} files · {} freed\n",
to_remove.len(),
human_bytes(total)
);
let confirmed = yes || confirm(" Proceed? [y/N]");
if !confirmed {
println!("Aborted.");
return Ok(());
}
for (path, _) in &to_remove {
fs::remove_file(path)?;
}
let mut meta = storage::load_meta()?;
meta.last_prune = Some(chrono::Utc::now().to_rfc3339());
storage::save_meta(&meta)?;
println!("Done. Removed {} files.", to_remove.len());
Ok(())
}
fn run_clear() -> Result<()> {
let s = storage::status()?;
let snap_count: usize = s.languages.iter().map(|l| l.count).sum();
println!("\n This will remove:");
println!(
" {} snapshot files {}",
snap_count,
human_bytes(s.total_bytes - s.gh_cache_bytes)
);
println!(
" gh_stats.json cache {}\n",
human_bytes(s.gh_cache_bytes)
);
print!(" Type \"yes\" to confirm: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if input.trim() != "yes" {
println!("Aborted.");
return Ok(());
}
let cache_dir = storage::cache_dir()?;
let snaps_dir = cache_dir.join("snapshots");
if snaps_dir.exists() {
std::fs::remove_dir_all(&snaps_dir)?;
}
let gh = cache_dir.join("gh_stats.json");
if gh.exists() {
std::fs::remove_file(&gh)?;
}
println!("Cleared.");
Ok(())
}
fn run_config(arg: Option<&str>) -> Result<()> {
let mut meta = storage::load_meta()?;
match arg {
None => {
let token_display = storage::load_github_token()
.map(|t| mask_token(&t))
.unwrap_or_else(|| "(not set — falls back to GITHUB_TOKEN env var)".into());
println!("\n retention keep_weeks = {}", meta.config.keep_weeks);
println!(" storage compress = {}", meta.config.compress);
println!(" github token = {}", token_display);
println!(" stored in ~/.config/hexplorer/credentials.json (mode 0600)");
println!();
}
Some(kv) => {
let (key, val) = kv
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("expected key=value, got '{kv}'"))?;
match key.trim() {
"keep_weeks" => {
meta.config.keep_weeks = val
.trim()
.parse()
.map_err(|_| anyhow::anyhow!("keep_weeks must be an integer"))?;
storage::save_meta(&meta)?;
println!("keep_weeks set to {}", meta.config.keep_weeks);
}
"compress" => {
meta.config.compress = matches!(val.trim(), "true" | "1" | "yes");
storage::save_meta(&meta)?;
println!("compress set to {}", meta.config.compress);
}
"github_token" => {
let t = val.trim();
storage::save_github_token(if t.is_empty() { None } else { Some(t) })?;
if t.is_empty() {
println!("github_token cleared.");
} else {
println!(
"github_token set ({}) → ~/.config/hexplorer/credentials.json (0600)",
mask_token(t)
);
}
return Ok(()); }
other => anyhow::bail!(
"unknown config key: '{other}' (valid: keep_weeks, compress, github_token)"
),
}
}
}
Ok(())
}
fn mask_token(t: &str) -> String {
let chars: Vec<char> = t.chars().collect();
if chars.len() <= 8 {
"***".to_string()
} else {
let head: String = chars[..4].iter().collect();
let tail: String = chars[chars.len() - 4..].iter().collect();
format!("{}…{}", head, tail)
}
}
fn human_bytes(b: u64) -> String {
if b >= 1_000_000 {
format!("{:.1} MB", b as f64 / 1_000_000.0)
} else if b >= 1_000 {
format!("{:.1} KB", b as f64 / 1_000.0)
} else {
format!("{b} B")
}
}
fn confirm(prompt: &str) -> bool {
print!("{prompt} ");
io::stdout().flush().ok();
let mut buf = String::new();
io::stdin().read_line(&mut buf).ok();
matches!(buf.trim().to_lowercase().as_str(), "y" | "yes")
}