use camino::Utf8PathBuf;
use clap::{Parser, Subcommand};
use diesel_guard::ast_dump;
use diesel_guard::output::OutputFormatter;
use diesel_guard::violation::Severity;
use diesel_guard::{Config, SafetyChecker};
use miette::{IntoDiagnostic, Result};
use std::fs;
use std::io::Write;
use std::process::exit;
const CONFIG_TEMPLATE: &str = include_str!("../diesel-guard.toml.example");
#[derive(Parser)]
#[command(
name = "diesel-guard",
version,
about = "Catch unsafe Postgres migrations in Diesel and SQLx before they take down production",
long_about = "Catch unsafe Postgres migrations in Diesel and SQLx before they take down production.
diesel-guard parses SQL with PostgreSQL's own parser (libpg_query) and flags operations
that acquire dangerous locks or cause table rewrites.
QUICK START:
diesel-guard init Create diesel-guard.toml in the current directory
diesel-guard check Check all migrations in ./migrations/
diesel-guard check up.sql Check a single file
diesel-guard check - Read SQL from stdin
Exit codes:
0 No violations found (warnings do not affect exit code)
1 One or more errors found (or a fatal error occurred)"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(long_about = "Check migrations for unsafe operations.
PATH can be:
- A directory — scans all up.sql files recursively
- A single .sql file
- \"-\" to read from stdin
If PATH is omitted, defaults to \"migrations/\".
diesel-guard looks for diesel-guard.toml in the current directory. If no config
file is found, default settings are used with a warning.
Exit codes:
0 No errors found (warnings do not affect exit code)
1 One or more errors found
EXAMPLES:
diesel-guard check
diesel-guard check migrations/
diesel-guard check db/migrate/20240101_add_users/up.sql
cat migration.sql | diesel-guard check -
diesel-guard check migrations/ --format json")]
Check {
path: Option<Utf8PathBuf>,
#[arg(long, default_value = "text")]
format: String,
},
#[command(long_about = "Initialize diesel-guard configuration file.
Creates diesel-guard.toml in the current directory with all available options
documented. Edit the file to set your migration framework (\"diesel\" or \"sqlx\")
and any other options.
Use --force to regenerate the config file and reset it to defaults.
EXAMPLES:
diesel-guard init
diesel-guard init --force")]
Init {
#[arg(long)]
force: bool,
},
#[command(long_about = "Dump the pg_query AST for SQL as JSON.
Useful when writing custom Rhai checks — shows the exact AST structure that
your scripts receive. Provide either --sql for an inline string or --file for
a .sql file (not both).
EXAMPLES:
diesel-guard dump-ast --sql \"ALTER TABLE users ADD COLUMN email TEXT\"
diesel-guard dump-ast --file migrations/20240101/up.sql")]
DumpAst {
#[arg(long)]
sql: Option<String>,
#[arg(long)]
file: Option<Utf8PathBuf>,
},
}
fn run_check(path: &camino::Utf8Path, format: &str) -> Result<()> {
if !Utf8PathBuf::from("diesel-guard.toml").exists() {
eprintln!("Warning: No config file found. Using default configuration.");
}
let config = Config::load().map_err(|e| miette::miette!(e))?;
let checker = SafetyChecker::with_config(config);
let results = checker.check_path(path)?;
if results.is_empty() {
match format {
"json" => println!("[]"),
"github" => {}
_ => println!("{}", OutputFormatter::format_summary(0, 0)),
}
return Ok(());
}
let total_errors: usize = results
.iter()
.flat_map(|(_, v)| v)
.filter(|(_, v)| v.severity == Severity::Error)
.count();
match format {
"json" => {
println!("{}", OutputFormatter::format_json(&results));
}
"github" => {
for (file_path, violations) in &results {
print!("{}", OutputFormatter::format_github(file_path, violations));
}
}
_ => {
let total_warnings: usize = results
.iter()
.flat_map(|(_, v)| v)
.filter(|(_, v)| v.severity == Severity::Warning)
.count();
for (file_path, violations) in &results {
print!("{}", OutputFormatter::format_text(file_path, violations));
}
println!(
"{}",
OutputFormatter::format_summary(total_errors, total_warnings)
);
}
}
if total_errors > 0 {
let _ = std::io::stdout().flush();
exit(1);
}
Ok(())
}
fn main() -> Result<()> {
miette::set_hook(Box::new(|_| {
Box::new(
miette::MietteHandlerOpts::new()
.terminal_links(true)
.unicode(true)
.context_lines(3)
.build(),
)
}))?;
let cli = Cli::parse();
match cli.command {
Commands::Check { path, format } => {
let path = path.unwrap_or_else(|| Utf8PathBuf::from("migrations"));
run_check(&path, &format)?;
}
Commands::DumpAst { sql, file } => {
let sql_input = match (sql, file) {
(Some(s), _) => s,
(None, Some(path)) => fs::read_to_string(&path)
.into_diagnostic()
.map_err(|e| miette::miette!("Failed to read file '{}': {}", path, e))?,
(None, None) => {
eprintln!("Error: provide either --sql or --file");
exit(1);
}
};
let json = ast_dump::dump_ast(&sql_input)?;
println!("{json}");
}
Commands::Init { force } => {
let config_path = Utf8PathBuf::from("diesel-guard.toml");
let file_existed = config_path.exists();
if file_existed && !force {
eprintln!("Error: diesel-guard.toml already exists in current directory");
eprintln!("Use --force to overwrite the existing file");
exit(1);
}
fs::write(&config_path, CONFIG_TEMPLATE)
.into_diagnostic()
.map_err(|e| miette::miette!("Failed to write config file: {}", e))?;
if file_existed {
println!("✓ Overwrote diesel-guard.toml");
} else {
println!("✓ Created diesel-guard.toml");
}
println!();
println!("Next steps:");
println!(
"1. Edit diesel-guard.toml and set the 'framework' field to \"diesel\" or \"sqlx\""
);
println!("2. Customize other configuration options as needed");
println!("3. Run 'diesel-guard check' to check your migrations");
}
}
Ok(())
}