use crate::error::CliError;
use forgedb_compaction::{CompactionConfig, MaintenanceApi};
use std::path::PathBuf;
#[allow(dead_code)]
pub struct CompactOptions {
pub data_dir: PathBuf,
pub model: Option<String>,
pub all: bool,
pub threshold: Option<f64>,
}
const DEPRECATED_COMPACT_MSG: &str = "\
Offline `forgedb compact` / `vacuum` is REMOVED (issue #105). Its tombstone-based \
algorithm is unsafe on data written by the generated mutation surface \
(updates/deletes): it reclaims nothing from updates and can RESURRECT deleted rows.\n\n\
Use the supported path instead: generated databases compact automatically \
in-process under the single-writer lock (every COMPACTION_DEAD_THRESHOLD dead \
versions), or call `Database::compact()` from your running app. That path is \
keep-set-based (it keeps exactly the live rows), so it never resurrects deletes.";
pub struct StatsOptions {
pub data_dir: PathBuf,
pub model: Option<String>,
pub json: bool,
}
#[allow(dead_code)]
pub struct VacuumOptions {
pub data_dir: PathBuf,
}
pub struct AnalyzeOptions {
pub data_dir: PathBuf,
pub json: bool,
}
pub fn compact(_opts: CompactOptions) -> Result<(), CliError> {
Err(CliError::Compaction(DEPRECATED_COMPACT_MSG.to_string()))
}
pub fn stats(opts: StatsOptions) -> Result<(), CliError> {
let config = CompactionConfig::default();
let api = MaintenanceApi::new(&opts.data_dir, config);
if let Some(model) = opts.model {
let stats = api.model_stats(&model).map_err(map_err)?;
if opts.json {
let json = serde_json::to_string_pretty(&stats).map_err(|e| map_err(e.to_string()))?;
println!("{}", json);
} else {
print_model_stats(&stats);
}
} else {
let stats = api.stats().map_err(map_err)?;
if opts.json {
let json = serde_json::to_string_pretty(&stats).map_err(|e| map_err(e.to_string()))?;
println!("{}", json);
} else {
print_database_stats(&stats);
}
}
Ok(())
}
pub fn vacuum(_opts: VacuumOptions) -> Result<(), CliError> {
Err(CliError::Compaction(DEPRECATED_COMPACT_MSG.to_string()))
}
pub fn analyze(opts: AnalyzeOptions) -> Result<(), CliError> {
let config = CompactionConfig::default();
let api = MaintenanceApi::new(&opts.data_dir, config.clone());
let stats = api.analyze().map_err(map_err)?;
if opts.json {
let json = serde_json::to_string_pretty(&stats).map_err(|e| map_err(e.to_string()))?;
println!("{}", json);
} else {
print_database_stats(&stats);
println!("\nRecommendations:");
let models_needing_compact = stats.models_needing_compaction(&config);
if models_needing_compact.is_empty() {
println!(" ✓ No models need compaction");
} else {
println!(
" ⚠ {} model(s) exceed dead space threshold:",
models_needing_compact.len()
);
for model in models_needing_compact {
println!(
" - {} ({:.1}% dead space)",
model.name,
model.dead_space_ratio * 100.0
);
}
println!(
"\n Compact in-process: generated databases reclaim this space \
automatically under the single-writer lock, or call \
`Database::compact()` from your app (offline `forgedb compact` is \
deprecated — see #105)."
);
}
}
Ok(())
}
fn print_database_stats(stats: &forgedb_compaction::DatabaseStats) {
println!("\nDatabase Statistics");
println!("===================");
println!(
"Collected at: {}",
stats.collected_at.format("%Y-%m-%d %H:%M:%S")
);
println!();
println!("Storage:");
println!(
" Total disk usage: {}",
format_bytes(stats.total_disk_bytes)
);
println!(" Used space: {}", format_bytes(stats.used_bytes));
println!(" Dead space: {}", format_bytes(stats.dead_bytes));
println!(
" Dead space ratio: {:.1}%",
stats.dead_space_ratio * 100.0
);
println!();
println!("Models: {}", stats.models.len());
println!();
if !stats.models.is_empty() {
println!(
"{:<20} {:>12} {:>12} {:>12} {:>10}",
"Model", "Total", "Used", "Dead", "Dead %"
);
println!("{}", "=".repeat(70));
for model in &stats.models {
println!(
"{:<20} {:>12} {:>12} {:>12} {:>9.1}%",
model.name,
format_bytes(model.total_disk_bytes),
format_bytes(model.used_bytes),
format_bytes(model.dead_bytes),
model.dead_space_ratio * 100.0
);
}
}
}
fn print_model_stats(stats: &forgedb_compaction::ModelStats) {
println!("\nModel: {}", stats.name);
println!("===================");
println!();
println!("Rows:");
println!(" Total: {}", stats.total_rows);
println!(" Active: {}", stats.active_rows);
println!(" Deleted: {}", stats.deleted_rows);
println!();
println!("Storage:");
println!(
" Total disk usage: {}",
format_bytes(stats.total_disk_bytes)
);
println!(" Used space: {}", format_bytes(stats.used_bytes));
println!(" Dead space: {}", format_bytes(stats.dead_bytes));
println!(
" Dead space ratio: {:.1}%",
stats.dead_space_ratio * 100.0
);
if let Some(last_compact) = stats.last_compaction {
println!();
println!(
"Last compaction: {}",
last_compact.format("%Y-%m-%d %H:%M:%S")
);
}
if !stats.columns.is_empty() {
println!();
println!("Columns:");
println!(
"{:<20} {:>10} {:>12} {:>12} {:>10}",
"Name", "Type", "Total", "Dead", "Dead %"
);
println!("{}", "=".repeat(68));
for col in &stats.columns {
let col_type = match col.column_type {
forgedb_compaction::ColumnType::Fixed => "Fixed",
forgedb_compaction::ColumnType::Variable => "Variable",
};
println!(
"{:<20} {:>10} {:>12} {:>12} {:>9.1}%",
col.name,
col_type,
format_bytes(col.total_bytes),
format_bytes(col.dead_bytes),
col.dead_space_ratio * 100.0
);
}
}
if !stats.index_sizes.is_empty() {
println!();
println!("Indexes:");
for (name, size) in &stats.index_sizes {
println!(" {}: {}", name, format_bytes(*size));
}
}
}
pub fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
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!("{} B", bytes)
}
}
fn map_err(e: String) -> CliError {
CliError::Compaction(e)
}