use anyhow::{Context, Result, anyhow};
use clap::{Parser, Subcommand};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use safe_migrate::sync;
use safe_migrate::{AnalysisState, Config, DbCache, Reporter, SafeMigrateEngine};
#[derive(Parser, Debug)]
#[command(name = "safe-migrate")]
#[command(version)]
#[command(about = "Lint PostgreSQL migrations to prevent blocking locks", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Lint {
#[arg(short, long)]
file: PathBuf,
#[arg(long, default_value = "safe-migrate.toml")]
config: PathBuf,
#[arg(long, default_value = ".safe-migrate-stats.json")]
cache: PathBuf,
#[arg(long)]
no_cache: bool,
},
Sync {
#[arg(long, default_value = ".safe-migrate-stats.json")]
out: PathBuf,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Lint {
file,
config: config_path,
cache,
no_cache,
} => {
let sql = fs::read_to_string(&file)
.with_context(|| format!("Failed to read migration file: {}", file.display()))?;
let cfg = Config::load_from_file(&config_path);
if !no_cache
&& cache.exists()
&& let Ok(metadata) = fs::metadata(&cache)
&& let Ok(modified) = metadata.modified()
{
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let file_time = modified
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now.saturating_sub(604_800) > file_time {
println!(
"[ WARN ] Database stats cache (.safe-migrate-stats.json) is over 7 days old!"
);
println!(
" Run `safe-migrate sync` to ensure accurate lock evaluations.\n"
);
}
}
let db_cache = if !no_cache && cache.exists() {
let json = fs::read_to_string(&cache).context("Failed to read cache file")?;
serde_json::from_str::<DbCache>(&json).map_err(|_| {
anyhow!("Cache file '{}' is corrupted (Invalid JSON). Run `safe-migrate sync` to rebuild it.", cache.display())
})?
} else {
if no_cache {
println!(
"[ INFO ] --no-cache passed. Running with default worst-case assumptions."
);
} else {
println!(
"[ INFO ] No cache found. Running with default worst-case assumptions."
);
}
DbCache::new() };
println!("\nAnalyzing migration: {}\n", file.display());
let engine = SafeMigrateEngine::new(cfg);
let mut state = AnalysisState::new(db_cache);
match engine.analyze(&sql, &mut state) {
Ok(violations) => {
let should_fail_ci =
Reporter::print_report(&violations, &state.local.confidence);
let has_warnings = violations.iter().any(|v| {
matches!(
v.tier,
safe_migrate::report::violations::ViolationTier::Tier2
)
});
if should_fail_ci {
return Err(anyhow!("[ HALT ] Migration halted: Tier 1 lock detected."));
} else if has_warnings {
println!(
"[ WARN ] Migration safe to deploy, but has warnings. Please review."
);
} else {
println!("[ SAFE ] Migration safe to deploy.");
}
}
Err(parse_errors) => {
eprintln!("CRITICAL: Failed to parse SQL migration:");
for err in parse_errors {
eprintln!(" - {}", err);
}
std::process::exit(1);
}
}
}
Commands::Sync { out } => {
let _db_url = std::env::var("DATABASE_URL")
.context("DATABASE_URL environment variable must be set to run sync.")?;
println!("Syncing database stats...");
sync::sync_cache(&out)?; println!("[ SAFE ] Cache successfully written to {}", out.display());
}
}
Ok(())
}