use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
mod analyze;
mod db;
mod identity;
mod live;
mod migrations;
mod schema;
mod verify;
#[allow(ambiguous_glob_reexports)]
pub use crate::analyze::*;
pub use crate::db::*;
pub use crate::live::*;
pub use crate::migrations::*;
pub use crate::schema::*;
pub use crate::verify::*;
pub use djogi_macros::{djogi_main, link_anchor};
pub use djogi::migrate::{DescriptorProvider, InventoryDescriptorProvider};
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")]
pub struct Cli {
#[command(subcommand)]
pub command: TopCommand,
}
#[derive(Subcommand)]
pub 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)]
pub 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>,
#[arg(long, conflicts_with = "single_node_dev")]
node_id: Option<u32>,
#[arg(long, default_value_t = false)]
single_node_dev: bool,
},
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)]
pub enum MigrateCommand {
Apply {
#[arg(long)]
workspace: Option<PathBuf>,
#[arg(long, default_value_t = false)]
fake: bool,
#[arg(long)]
reason: Option<String>,
#[arg(long, conflicts_with = "single_node_dev")]
node_id: Option<u32>,
#[arg(long, default_value_t = false)]
single_node_dev: bool,
},
}
#[derive(Subcommand)]
pub 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>,
#[arg(long, conflicts_with = "single_node_dev")]
node_id: Option<u32>,
#[arg(long, default_value_t = false)]
single_node_dev: bool,
},
Repair {
#[command(subcommand)]
command: RepairSubcommand,
},
Baseline {
version: String,
#[arg(long, default_value = "existing database schema baseline")]
description: String,
#[arg(long)]
reason: String,
#[arg(long)]
app: Option<String>,
#[arg(long)]
database: Option<String>,
#[arg(long)]
workspace: Option<PathBuf>,
#[arg(long, conflicts_with = "single_node_dev")]
node_id: Option<u32>,
#[arg(long, default_value_t = false)]
single_node_dev: bool,
},
}
#[derive(Clone, Subcommand)]
pub enum RepairSubcommand {
ChecksumDrift {
version: String,
#[arg(long)]
app: Option<String>,
#[arg(long)]
database: Option<String>,
#[arg(long)]
checksum_up: Option<String>,
#[arg(long)]
checksum_down: Option<String>,
#[arg(long)]
workspace: Option<PathBuf>,
},
PartialApply {
version: String,
#[arg(value_enum)]
resolution: PartialApplyResolutionCli,
#[arg(long, default_value = "operator resolved partial apply via CLI")]
note: String,
#[arg(long)]
app: Option<String>,
#[arg(long)]
database: Option<String>,
#[arg(long)]
workspace: Option<PathBuf>,
},
ResumePartial {
version: String,
#[arg(long)]
app: Option<String>,
#[arg(long)]
database: Option<String>,
#[arg(long)]
workspace: Option<PathBuf>,
#[arg(long, conflicts_with = "single_node_dev")]
node_id: Option<u32>,
#[arg(long, default_value_t = false)]
single_node_dev: bool,
},
SnapshotRebuild {
#[arg(long)]
app: Option<String>,
#[arg(long)]
database: Option<String>,
#[arg(long)]
snapshot_path: Option<PathBuf>,
#[arg(long)]
workspace: Option<PathBuf>,
},
}
#[derive(clap::ValueEnum, Clone, Debug)]
pub enum PartialApplyResolutionCli {
RolledBack,
Faked,
Applied,
}
pub fn run_from_env() -> ExitCode {
let cli = match Cli::try_parse_from(std::env::args_os()) {
Ok(c) => c,
Err(e) => {
let _ = e.print();
return ExitCode::from(if e.use_stderr() { 2 } else { 0 });
}
};
dispatch_command(
&cli.command,
&djogi::migrate::InventoryDescriptorProvider::new(),
)
}
pub fn run_with_args<I, T>(args: I) -> ExitCode
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let cli = match Cli::try_parse_from(args) {
Ok(c) => c,
Err(e) => {
let _ = e.print();
return ExitCode::from(if e.use_stderr() { 2 } else { 0 });
}
};
dispatch_command(
&cli.command,
&djogi::migrate::InventoryDescriptorProvider::new(),
)
}
pub fn run_with_provider<I, T>(
args: I,
provider: &dyn djogi::migrate::DescriptorProvider,
) -> ExitCode
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let cli = match Cli::try_parse_from(args) {
Ok(c) => c,
Err(e) => {
let _ = e.print();
return ExitCode::from(if e.use_stderr() { 2 } else { 0 });
}
};
dispatch_command(&cli.command, provider)
}
fn dispatch_command(
command: &TopCommand,
provider: &dyn djogi::migrate::DescriptorProvider,
) -> ExitCode {
match 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,
node_id,
single_node_dev,
} => db::reset_cmd(
*yes,
*allow_checksum_drift_reset,
maintenance_database.clone(),
workspace.clone(),
*node_id,
*single_node_dev,
),
DbCommand::Seed {
database,
allow_non_localhost,
workspace,
} => db::seed_cmd(database.clone(), *allow_non_localhost, workspace.clone()),
DbCommand::CleanupTestDbs {
dry_run,
yes,
maintenance_database,
allow_non_localhost,
workspace,
} => db::cleanup_test_dbs_cmd(
*dry_run,
*yes,
maintenance_database.clone(),
*allow_non_localhost,
workspace.clone(),
),
},
TopCommand::Docs { output, workspace } => {
if provider.models().is_empty() {
print_zero_descriptor_diagnostic("docs");
return ExitCode::from(2);
}
db::docs_cmd(provider, output.clone(), workspace.clone())
}
TopCommand::Live { command } => live::dispatch(command.clone()),
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.clone())) {
Ok(code) => code,
Err(e) => {
eprintln!("djogi verify: {e}");
ExitCode::from(1)
}
}
}
TopCommand::Schema { format, output } => {
let models: Vec<&'static djogi::descriptor::ModelDescriptor> = provider.models();
if models.is_empty() {
print_zero_descriptor_diagnostic("schema");
return ExitCode::from(2);
}
match schema::run(format.into_schema(), &models, output.clone()) {
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.clone(),
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,
} => {
if provider.models().is_empty() {
print_zero_descriptor_diagnostic("migrations compose");
return ExitCode::from(2);
}
migrations::compose_cmd(
provider,
name.as_str(),
*allow_destructive,
*force_overwrite,
workspace.clone(),
)
}
MigrationsCommand::Status { workspace } => migrations::status_cmd(workspace.clone()),
MigrationsCommand::Verify { workspace, strict } => {
migrations::verify_cmd(provider, workspace.clone(), *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.as_str(),
*squash,
from.as_deref(),
*publish,
app.as_deref(),
workspace.clone(),
),
MigrationsCommand::Apply {
workspace,
fake,
reason,
node_id,
single_node_dev,
} => migrations::apply_cmd(
workspace.clone(),
*fake,
reason.clone(),
*node_id,
*single_node_dev,
),
MigrationsCommand::Repair { command } => migrations::repair_cmd(command.clone()),
MigrationsCommand::Baseline {
version,
description,
reason,
app,
database,
workspace,
node_id,
single_node_dev,
} => migrations::baseline_cmd(
version,
description,
reason,
app.as_deref(),
database.as_deref(),
workspace.clone(),
*node_id,
*single_node_dev,
),
},
TopCommand::Migrate { command } => match command {
MigrateCommand::Apply {
workspace,
fake,
reason,
node_id,
single_node_dev,
} => migrations::apply_cmd(
workspace.clone(),
*fake,
reason.clone(),
*node_id,
*single_node_dev,
),
},
}
}
pub(crate) fn print_zero_descriptor_diagnostic(command: &str) {
eprintln!("error: no djogi models are registered in this binary (djogi {command}).");
eprintln!();
eprintln!("Descriptor-dependent commands (compose, verify, schema, docs) require a");
eprintln!("djogi binary linked with your model crates.");
eprintln!();
eprintln!(" • If you ran the standalone published `djogi`: that binary links no");
eprintln!(" application models. Build an adopter-linked `djogi` (see the adopter");
eprintln!(" CLI guide: docs/guide/adopter-cli.md) and run the command from it.");
eprintln!(" The standalone binary can still run `djogi migrations apply` against");
eprintln!(" already-composed pending artifacts.");
eprintln!();
eprintln!(" • If this IS your adopter-linked `djogi`: ensure your bin references");
eprintln!(" every crate that defines `#[derive(Model)]` (link_models / djogi_main!),");
eprintln!(" or the linker may have dropped an unreferenced model crate.");
}
#[cfg(test)]
pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
static ENV_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
ENV_LOCK
.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap()
}
#[cfg(test)]
mod tests {
use clap::Parser as _;
use std::path::PathBuf;
use super::{
Cli, DbCommand, MigrateCommand, MigrationsCommand, PartialApplyResolutionCli,
RepairSubcommand, 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"),
}
}
#[test]
fn parse_repair_checksum_drift_accepts_required_args() {
let cli = Cli::parse_from([
"djogi",
"migrations",
"repair",
"checksum-drift",
"V20260101000000__test",
"--checksum-up",
"V1:aaaa",
]);
assert!(matches!(cli.command, TopCommand::Migrations { .. }));
}
#[test]
fn parse_repair_checksum_drift_rejects_missing_version() {
let result = Cli::try_parse_from(["djogi", "migrations", "repair", "checksum-drift"]);
assert!(result.is_err(), "must require version argument");
}
#[test]
fn parse_repair_partial_apply_accepts_resolution_values() {
for resolution in ["rolled-back", "faked", "applied"] {
let cli = Cli::parse_from([
"djogi",
"migrations",
"repair",
"partial-apply",
"V20260101000000__test",
resolution,
]);
assert!(
matches!(cli.command, TopCommand::Migrations { .. }),
"resolution={resolution}"
);
}
}
#[test]
fn parse_repair_partial_apply_rejects_invalid_resolution() {
let result = Cli::try_parse_from([
"djogi",
"migrations",
"repair",
"partial-apply",
"V20260101000000__test",
"invalid-resolution",
]);
assert!(result.is_err(), "must reject unknown resolution");
}
#[test]
fn parse_repair_resume_partial_accepts_version() {
let cli = Cli::parse_from([
"djogi",
"migrations",
"repair",
"resume-partial",
"V20260101000000__test",
]);
assert!(matches!(cli.command, TopCommand::Migrations { .. }));
}
#[test]
fn parse_repair_snapshot_rebuild_accepts_flags() {
let cli = Cli::parse_from([
"djogi",
"migrations",
"repair",
"snapshot-rebuild",
"--app",
"myapp",
]);
assert!(matches!(cli.command, TopCommand::Migrations { .. }));
}
#[test]
fn parse_repair_checksum_drift_binds_version_and_checksum_up() {
let cli = Cli::parse_from([
"djogi",
"migrations",
"repair",
"checksum-drift",
"V20260101000000__add_users",
"--checksum-up",
"V1:aaaa",
]);
if let TopCommand::Migrations {
command: MigrationsCommand::Repair { command },
} = cli.command
{
if let RepairSubcommand::ChecksumDrift {
version,
checksum_up,
..
} = command
{
assert_eq!(version, "V20260101000000__add_users");
assert_eq!(checksum_up.as_deref(), Some("V1:aaaa"));
} else {
panic!("wrong variant");
}
} else {
panic!("wrong command");
}
}
#[test]
fn parse_repair_partial_apply_binds_resolution_and_note() {
let cli = Cli::parse_from([
"djogi",
"migrations",
"repair",
"partial-apply",
"V20260101000000__add_users",
"rolled-back",
"--note",
"reverted by hot-fix",
]);
if let TopCommand::Migrations {
command: MigrationsCommand::Repair { command },
} = cli.command
{
if let RepairSubcommand::PartialApply {
version,
resolution,
note,
..
} = command
{
assert_eq!(version, "V20260101000000__add_users");
assert!(matches!(resolution, PartialApplyResolutionCli::RolledBack));
assert_eq!(note, "reverted by hot-fix");
} else {
panic!("wrong variant");
}
} else {
panic!("wrong command");
}
}
#[test]
fn parse_repair_snapshot_rebuild_binds_app_and_database() {
let cli = Cli::parse_from([
"djogi",
"migrations",
"repair",
"snapshot-rebuild",
"--app",
"billing",
"--database",
"analytics",
]);
if let TopCommand::Migrations {
command: MigrationsCommand::Repair { command },
} = cli.command
{
if let RepairSubcommand::SnapshotRebuild { app, database, .. } = command {
assert_eq!(app.as_deref(), Some("billing"));
assert_eq!(database.as_deref(), Some("analytics"));
} else {
panic!("wrong variant");
}
} else {
panic!("wrong command");
}
}
fn baseline_command(cli: Cli) -> MigrationsCommand {
match cli.command {
TopCommand::Migrations {
command: command @ MigrationsCommand::Baseline { .. },
} => command,
_ => panic!("expected migrations baseline command"),
}
}
#[test]
fn parse_baseline_accepts_required_args() {
let cli = Cli::try_parse_from([
"djogi",
"migrations",
"baseline",
"V00000000000000__baseline",
"--reason",
"schema pre-exists from prior tooling",
])
.unwrap();
let MigrationsCommand::Baseline {
version,
reason,
description,
app,
database,
..
} = baseline_command(cli)
else {
panic!("expected Baseline");
};
assert_eq!(version, "V00000000000000__baseline");
assert_eq!(reason, "schema pre-exists from prior tooling");
assert_eq!(description, "existing database schema baseline");
assert!(app.is_none());
assert!(database.is_none());
}
#[test]
fn parse_baseline_rejects_missing_version() {
let result = Cli::try_parse_from(["djogi", "migrations", "baseline", "--reason", "test"]);
assert!(
result.is_err(),
"baseline without version positional should fail"
);
}
#[test]
fn parse_baseline_rejects_missing_reason() {
let result = Cli::try_parse_from([
"djogi",
"migrations",
"baseline",
"V00000000000000__baseline",
]);
assert!(result.is_err(), "baseline without --reason should fail");
}
#[test]
fn parse_baseline_accepts_optional_flags() {
let cli = Cli::try_parse_from([
"djogi",
"migrations",
"baseline",
"V00000000000000__baseline",
"--reason",
"existing schema",
"--description",
"custom description",
"--app",
"billing",
"--database",
"crud_log",
])
.unwrap();
let MigrationsCommand::Baseline {
version,
reason,
description,
app,
database,
..
} = baseline_command(cli)
else {
panic!("expected Baseline");
};
assert_eq!(version, "V00000000000000__baseline");
assert_eq!(reason, "existing schema");
assert_eq!(description, "custom description");
assert_eq!(app.as_deref(), Some("billing"));
assert_eq!(database.as_deref(), Some("crud_log"));
}
}