use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
mod analyze;
mod db;
mod live;
mod migrations;
mod schema;
mod verify;
pub fn print_support_boundary_error(subcommand: &str, err: &dyn std::fmt::Display) {
eprintln!("djogi {subcommand}: support boundary: {err}");
}
#[derive(Parser)]
#[command(name = "djogi", about = "Djogi framework CLI")]
struct Cli {
#[command(subcommand)]
command: TopCommand,
}
#[derive(Subcommand)]
enum TopCommand {
Shell,
Db {
#[command(subcommand)]
command: DbCommand,
},
Migrations {
#[command(subcommand)]
command: MigrationsCommand,
},
Migrate {
#[command(subcommand)]
command: MigrateCommand,
},
Live {
#[command(subcommand)]
command: live::LiveCmd,
},
Docs {
#[arg(long)]
output: Option<PathBuf>,
#[arg(long)]
workspace: Option<PathBuf>,
},
Verify {
#[arg(long)]
workspace: Option<PathBuf>,
},
Schema {
#[arg(long, value_enum, default_value_t = SchemaFormat::Json)]
format: SchemaFormat,
#[arg(long)]
output: Option<PathBuf>,
},
Analyze {
#[arg(long, value_enum, default_value_t = AnalyzeFormat::Human)]
format: AnalyzeFormat,
#[arg(long, default_value_t = 0.2, value_parser = parse_threshold_vacuum)]
threshold_vacuum: f64,
#[arg(long, default_value_t = 10_000_000)]
threshold_partition_rows: i64,
#[arg(long)]
workspace: Option<PathBuf>,
},
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum SchemaFormat {
Json,
}
impl SchemaFormat {
fn into_schema(self) -> schema::SchemaFormat {
match self {
SchemaFormat::Json => schema::SchemaFormat::Json,
}
}
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum AnalyzeFormat {
Human,
Json,
}
impl AnalyzeFormat {
fn into_analyze(self) -> analyze::AnalyzeFormat {
match self {
AnalyzeFormat::Human => analyze::AnalyzeFormat::Human,
AnalyzeFormat::Json => analyze::AnalyzeFormat::Json,
}
}
}
fn parse_threshold_vacuum(s: &str) -> Result<f64, String> {
let v: f64 = s
.parse()
.map_err(|e: std::num::ParseFloatError| e.to_string())?;
if !v.is_finite() {
return Err(format!("threshold_vacuum must be finite (got {s})"));
}
if !(0.0..=1.0).contains(&v) {
return Err(format!("threshold_vacuum must be in [0.0, 1.0] (got {v})"));
}
Ok(v)
}
#[derive(Subcommand)]
enum DbCommand {
Reset {
#[arg(long, default_value_t = false)]
yes: bool,
#[arg(long, default_value_t = false)]
allow_checksum_drift_reset: bool,
#[arg(long, default_value = "postgres")]
maintenance_database: String,
#[arg(long)]
workspace: Option<PathBuf>,
},
Seed {
#[arg(long, default_value = "main")]
database: String,
#[arg(long, default_value_t = false)]
allow_non_localhost: bool,
#[arg(long)]
workspace: Option<PathBuf>,
},
CleanupTestDbs {
#[arg(long, default_value_t = false)]
dry_run: bool,
#[arg(long, default_value_t = false)]
yes: bool,
#[arg(long, default_value = "postgres")]
maintenance_database: String,
#[arg(long, default_value_t = false)]
allow_non_localhost: bool,
#[arg(long)]
workspace: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum MigrateCommand {
Apply {
#[arg(long)]
workspace: Option<PathBuf>,
#[arg(long, default_value_t = false)]
fake: bool,
#[arg(long)]
reason: Option<String>,
},
}
#[derive(Subcommand)]
enum MigrationsCommand {
Compose {
#[arg(long, default_value = "")]
name: String,
#[arg(long, default_value_t = false)]
allow_destructive: bool,
#[arg(long, default_value_t = false)]
force_overwrite: bool,
#[arg(long)]
workspace: Option<PathBuf>,
},
Status {
#[arg(long)]
workspace: Option<PathBuf>,
},
Verify {
#[arg(long)]
workspace: Option<PathBuf>,
#[arg(long, default_value_t = false)]
strict: bool,
},
Attune {
target: Option<String>,
#[arg(long, default_value_t = false)]
apply: bool,
#[arg(long, default_value_t = false)]
record: bool,
#[arg(
long = "record-ledger",
default_value_t = false,
conflicts_with = "squash"
)]
record_ledger: bool,
#[arg(long, default_value = "operator asserted out-of-band apply")]
record_reason: String,
#[arg(long, default_value_t = false)]
squash: bool,
#[arg(long)]
from: Option<String>,
#[arg(long, default_value_t = false)]
publish: bool,
#[arg(long)]
app: Option<String>,
#[arg(long)]
workspace: Option<PathBuf>,
},
Apply {
#[arg(long)]
workspace: Option<PathBuf>,
#[arg(long, default_value_t = false)]
fake: bool,
#[arg(long)]
reason: Option<String>,
},
}
fn main() -> ExitCode {
let cli = Cli::parse();
match cli.command {
TopCommand::Shell => {
eprintln!("djogi shell: not yet implemented");
ExitCode::from(0)
}
TopCommand::Db { command } => match command {
DbCommand::Reset {
yes,
allow_checksum_drift_reset,
maintenance_database,
workspace,
} => db::reset_cmd(
yes,
allow_checksum_drift_reset,
maintenance_database,
workspace,
),
DbCommand::Seed {
database,
allow_non_localhost,
workspace,
} => db::seed_cmd(database, allow_non_localhost, workspace),
DbCommand::CleanupTestDbs {
dry_run,
yes,
maintenance_database,
allow_non_localhost,
workspace,
} => db::cleanup_test_dbs_cmd(
dry_run,
yes,
maintenance_database,
allow_non_localhost,
workspace,
),
},
TopCommand::Docs { output, workspace } => db::docs_cmd(output, workspace),
TopCommand::Live { command } => live::dispatch(command),
TopCommand::Verify { workspace } => {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(r) => r,
Err(e) => {
eprintln!("djogi verify: tokio runtime: {e}");
return ExitCode::from(1);
}
};
match runtime.block_on(verify::run(workspace)) {
Ok(code) => code,
Err(e) => {
eprintln!("djogi verify: {e}");
ExitCode::from(1)
}
}
}
TopCommand::Schema { format, output } => match schema::run(format.into_schema(), output) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("djogi schema: {e}");
ExitCode::from(1)
}
},
TopCommand::Analyze {
format,
threshold_vacuum,
threshold_partition_rows,
workspace,
} => {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(r) => r,
Err(e) => {
eprintln!("djogi analyze: tokio runtime: {e}");
return ExitCode::from(1);
}
};
match runtime.block_on(analyze::run(
workspace,
format.into_analyze(),
threshold_vacuum,
threshold_partition_rows,
)) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("djogi analyze: {e}");
ExitCode::from(1)
}
}
}
TopCommand::Migrations { command } => match command {
MigrationsCommand::Compose {
name,
allow_destructive,
force_overwrite,
workspace,
} => migrations::compose_cmd(&name, allow_destructive, force_overwrite, workspace),
MigrationsCommand::Status { workspace } => migrations::status_cmd(workspace),
MigrationsCommand::Verify { workspace, strict } => {
migrations::verify_cmd(workspace, strict)
}
MigrationsCommand::Attune {
target,
apply,
record,
record_ledger,
record_reason,
squash,
from,
publish,
app,
workspace,
} => migrations::attune_cmd(
target.as_deref(),
apply,
record,
record_ledger,
&record_reason,
squash,
from.as_deref(),
publish,
app.as_deref(),
workspace,
),
MigrationsCommand::Apply {
workspace,
fake,
reason,
} => migrations::apply_cmd(workspace, fake, reason),
},
TopCommand::Migrate { command } => match command {
MigrateCommand::Apply {
workspace,
fake,
reason,
} => migrations::apply_cmd(workspace, fake, reason),
},
}
}
#[cfg(test)]
mod tests {
use clap::Parser as _;
use std::path::PathBuf;
use super::{
Cli, DbCommand, MigrateCommand, MigrationsCommand, TopCommand, parse_threshold_vacuum,
};
#[test]
fn parse_threshold_vacuum_accepts_valid_values() {
assert_eq!(parse_threshold_vacuum("0.0").unwrap(), 0.0);
assert_eq!(parse_threshold_vacuum("0.2").unwrap(), 0.2);
assert_eq!(parse_threshold_vacuum("1.0").unwrap(), 1.0);
assert_eq!(parse_threshold_vacuum("0.5").unwrap(), 0.5);
}
#[test]
fn parse_threshold_vacuum_rejects_nan_inf_and_out_of_range() {
let err = parse_threshold_vacuum("NaN").unwrap_err();
assert!(err.contains("finite"), "err: {err}");
let err = parse_threshold_vacuum("inf").unwrap_err();
assert!(err.contains("finite"), "err: {err}");
let err = parse_threshold_vacuum("-inf").unwrap_err();
assert!(err.contains("finite"), "err: {err}");
let err = parse_threshold_vacuum("-0.1").unwrap_err();
assert!(err.contains("[0.0, 1.0]"), "err: {err}");
let err = parse_threshold_vacuum("1.5").unwrap_err();
assert!(err.contains("[0.0, 1.0]"), "err: {err}");
assert!(parse_threshold_vacuum("not-a-number").is_err());
}
#[test]
fn db_reset_parses_allow_checksum_drift_reset_flag() {
let cli = Cli::try_parse_from([
"djogi",
"db",
"reset",
"--yes",
"--allow-checksum-drift-reset",
])
.expect("flag should parse");
match cli.command {
TopCommand::Db {
command:
DbCommand::Reset {
yes,
allow_checksum_drift_reset,
..
},
} => {
assert!(yes, "--yes should parse through");
assert!(
allow_checksum_drift_reset,
"checksum-drift override flag should parse through"
);
}
_ => panic!("expected db reset command"),
}
}
#[test]
fn migrate_apply_alias_parses() {
let cli = Cli::try_parse_from(["djogi", "migrate", "apply"])
.expect("migrate apply should parse as alias");
match cli.command {
TopCommand::Migrate {
command: MigrateCommand::Apply { .. },
} => {}
_ => panic!("expected migrate apply command"),
}
}
#[test]
fn canonical_migrations_apply_parses() {
let cli = Cli::try_parse_from(["djogi", "migrations", "apply"])
.expect("canonical migrations apply should parse");
match cli.command {
TopCommand::Migrations {
command: MigrationsCommand::Apply { .. },
} => {}
_ => panic!("expected migrations apply command"),
}
}
#[test]
fn canonical_migrations_status_still_parses() {
let cli = Cli::try_parse_from(["djogi", "migrations", "status"])
.expect("canonical migrations status should parse");
match cli.command {
TopCommand::Migrations {
command: MigrationsCommand::Status { .. },
} => {}
_ => panic!("expected migrations status command"),
}
}
#[test]
fn migrations_verify_parses_with_defaults() {
let cli = Cli::try_parse_from(["djogi", "migrations", "verify"])
.expect("migrations verify should parse with no flags");
match cli.command {
TopCommand::Migrations {
command: MigrationsCommand::Verify { workspace, strict },
} => {
assert!(workspace.is_none());
assert!(!strict);
}
_ => panic!("expected migrations verify command"),
}
}
#[test]
fn migrations_verify_parses_with_strict() {
let cli = Cli::try_parse_from(["djogi", "migrations", "verify", "--strict"])
.expect("migrations verify --strict should parse");
match cli.command {
TopCommand::Migrations {
command: MigrationsCommand::Verify { strict, .. },
} => {
assert!(strict);
}
_ => panic!("expected migrations verify command"),
}
}
#[test]
fn migrations_verify_parses_with_workspace() {
let cli = Cli::try_parse_from([
"djogi",
"migrations",
"verify",
"--workspace",
"/custom/path",
])
.expect("migrations verify --workspace should parse");
match cli.command {
TopCommand::Migrations {
command: MigrationsCommand::Verify { workspace, .. },
} => {
assert_eq!(workspace, Some(PathBuf::from("/custom/path")));
}
_ => panic!("expected migrations verify command"),
}
}
}