use std::io::{self, Write};
use anyhow::{bail, Result};
use chrono::{Duration, Utc};
use colored::Colorize;
use crate::storage::Database;
#[derive(clap::Args)]
#[command(after_help = "EXAMPLES:\n \
lore db stats Show database statistics\n \
lore db vacuum Reclaim unused space\n \
lore db prune --older-than 90d Delete sessions older than 90 days\n \
lore db prune --older-than 6m --dry-run Preview what would be deleted")]
pub struct Args {
#[command(subcommand)]
pub command: DbCommand,
}
#[derive(clap::Subcommand)]
pub enum DbCommand {
#[command(
long_about = "Runs SQLite VACUUM to rebuild the database file and reclaim\n\
unused space. This can take some time on large databases and\n\
temporarily uses extra disk space during the operation."
)]
Vacuum,
#[command(
long_about = "Deletes sessions older than the specified duration along with\n\
all their messages and links. Use --dry-run to preview what\n\
would be deleted without making changes."
)]
Prune(PruneArgs),
#[command(
long_about = "Displays statistics about the Lore database including:\n\
- Total sessions, messages, and links\n\
- Database file size\n\
- Date range of sessions\n\
- Breakdown by AI tool"
)]
Stats,
}
#[derive(clap::Args)]
pub struct PruneArgs {
#[arg(long, value_name = "DURATION")]
#[arg(
long_help = "Duration string specifying how old sessions must be to delete.\n\
Supported formats:\n \
- Nd: N days (e.g., 90d)\n \
- Nw: N weeks (e.g., 12w)\n \
- Nm: N months (e.g., 6m)\n \
- Ny: N years (e.g., 1y)"
)]
pub older_than: String,
#[arg(long)]
#[arg(long_help = "Preview mode: shows which sessions would be deleted\n\
without actually removing them from the database.")]
pub dry_run: bool,
#[arg(long)]
#[arg(
long_help = "Skip the confirmation prompt and proceed with deletion.\n\
Use with caution as this operation cannot be undone."
)]
pub force: bool,
}
pub fn run(args: Args) -> Result<()> {
match args.command {
DbCommand::Vacuum => run_vacuum(),
DbCommand::Prune(prune_args) => run_prune(prune_args),
DbCommand::Stats => run_stats(),
}
}
fn run_vacuum() -> Result<()> {
let db = Database::open_default()?;
let size_before = db.file_size()?.unwrap_or(0);
println!("{}", "Running VACUUM...".dimmed());
db.vacuum()?;
let size_after = db.file_size()?.unwrap_or(0);
let saved = size_before.saturating_sub(size_after);
println!("{} Database vacuumed successfully", "Done.".green().bold());
println!(" {} {}", "Before:".dimmed(), format_size(size_before));
println!(" {} {}", "After:".dimmed(), format_size(size_after));
if saved > 0 {
println!(" {} {}", "Saved:".dimmed(), format_size(saved).green());
}
Ok(())
}
fn run_prune(args: PruneArgs) -> Result<()> {
let db = Database::open_default()?;
let duration = parse_duration(&args.older_than)?;
let cutoff = Utc::now() - duration;
let count = db.count_sessions_older_than(cutoff)?;
if count == 0 {
println!(
"{}",
format!("No sessions older than {} found.", args.older_than).dimmed()
);
return Ok(());
}
let cutoff_display = cutoff.format("%Y-%m-%d");
if args.dry_run {
let sessions = db.get_sessions_older_than(cutoff)?;
println!(
"Sessions that would be deleted ({} total):",
count.to_string().yellow()
);
println!();
const ID_WIDTH: usize = 8;
const STARTED_WIDTH: usize = 16;
const MESSAGES_WIDTH: usize = 8;
const BRANCH_WIDTH: usize = 12;
println!(
"{}",
format!(
"{:<ID_WIDTH$} {:<STARTED_WIDTH$} {:>MESSAGES_WIDTH$} {:<BRANCH_WIDTH$} {}",
"ID", "STARTED", "MESSAGES", "BRANCH", "DIRECTORY"
)
.bold()
);
for session in &sessions {
let id_short = &session.id.to_string()[..8];
let started = session.started_at.format("%Y-%m-%d %H:%M").to_string();
let branch = session.git_branch.as_deref().unwrap_or("-");
let dir = session
.working_directory
.split('/')
.next_back()
.unwrap_or(&session.working_directory);
println!(
"{:<ID_WIDTH$} {:<STARTED_WIDTH$} {:>MESSAGES_WIDTH$} {:<BRANCH_WIDTH$} {}",
id_short.cyan(),
started.dimmed(),
session.message_count,
branch.yellow(),
dir
);
}
println!();
println!("{}", "(Dry run - no changes made)".dimmed());
return Ok(());
}
println!(
"Found {} {} started before {}",
count.to_string().yellow(),
if count == 1 { "session" } else { "sessions" },
cutoff_display.to_string().cyan()
);
if !args.force {
println!();
print!(
"Delete {} {}? [y/N] ",
count,
if count == 1 { "session" } else { "sessions" }
);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("{}", "Cancelled".dimmed());
return Ok(());
}
}
let deleted = db.delete_sessions_older_than(cutoff)?;
println!(
"{} {} {}",
"Deleted".green(),
deleted,
if deleted == 1 { "session" } else { "sessions" }
);
Ok(())
}
fn run_stats() -> Result<()> {
let db = Database::open_default()?;
let stats = db.stats()?;
let file_size = db.file_size()?.unwrap_or(0);
println!("{}", "Database Statistics".bold());
println!();
println!(" {} {}", "Sessions:".dimmed(), stats.session_count);
println!(" {} {}", "Messages:".dimmed(), stats.message_count);
println!(" {} {}", "Links:".dimmed(), stats.link_count);
println!(" {} {}", "File size:".dimmed(), format_size(file_size));
if let (Some(oldest), Some(newest)) = (stats.oldest_session, stats.newest_session) {
println!();
println!("{}", "Date Range".bold());
println!(
" {} {}",
"Oldest:".dimmed(),
oldest.format("%Y-%m-%d %H:%M")
);
println!(
" {} {}",
"Newest:".dimmed(),
newest.format("%Y-%m-%d %H:%M")
);
}
if !stats.sessions_by_tool.is_empty() {
println!();
println!("{}", "Sessions by Tool".bold());
for (tool, count) in &stats.sessions_by_tool {
println!(" {} {}", format!("{:>14}:", tool).dimmed(), count);
}
}
Ok(())
}
fn parse_duration(s: &str) -> Result<Duration> {
let s = s.trim().to_lowercase();
if s.is_empty() {
bail!("Duration string cannot be empty");
}
let unit = s.chars().last().unwrap();
let number_part = &s[..s.len() - 1];
let number: i64 = number_part.parse().map_err(|_| {
anyhow::anyhow!(
"Invalid duration '{}'. Expected format: <number><unit> (e.g., 90d, 6m, 1y)",
s
)
})?;
if number <= 0 {
bail!("Duration must be a positive number");
}
let days = match unit {
'd' => number,
'w' => number * 7,
'm' => number * 30,
'y' => number * 365,
_ => bail!(
"Unknown duration unit '{}'. Supported units: d (days), w (weeks), m (months), y (years)",
unit
),
};
Ok(Duration::days(days))
}
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} bytes", bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_duration_days() {
let d = parse_duration("90d").unwrap();
assert_eq!(d.num_days(), 90);
}
#[test]
fn test_parse_duration_weeks() {
let d = parse_duration("4w").unwrap();
assert_eq!(d.num_days(), 28);
}
#[test]
fn test_parse_duration_months() {
let d = parse_duration("6m").unwrap();
assert_eq!(d.num_days(), 180);
}
#[test]
fn test_parse_duration_years() {
let d = parse_duration("1y").unwrap();
assert_eq!(d.num_days(), 365);
}
#[test]
fn test_parse_duration_uppercase() {
let d = parse_duration("30D").unwrap();
assert_eq!(d.num_days(), 30);
}
#[test]
fn test_parse_duration_invalid_unit() {
let result = parse_duration("30x");
assert!(result.is_err());
}
#[test]
fn test_parse_duration_invalid_number() {
let result = parse_duration("abcd");
assert!(result.is_err());
}
#[test]
fn test_parse_duration_empty() {
let result = parse_duration("");
assert!(result.is_err());
}
#[test]
fn test_parse_duration_negative() {
let result = parse_duration("-30d");
assert!(result.is_err());
}
#[test]
fn test_format_size_bytes() {
assert_eq!(format_size(500), "500 bytes");
}
#[test]
fn test_format_size_kb() {
assert_eq!(format_size(1536), "1.50 KB");
}
#[test]
fn test_format_size_mb() {
assert_eq!(format_size(5 * 1024 * 1024), "5.00 MB");
}
#[test]
fn test_format_size_gb() {
assert_eq!(format_size(2 * 1024 * 1024 * 1024), "2.00 GB");
}
}