djogi_cli/lib.rs
1//! Djogi CLI library — entry points for the `djogi` binary and for
2//! adopter-linked binaries that inject their own [`DescriptorProvider`].
3//! The published standalone `djogi` binary links no adopter model crates,
4//! so reading the global `inventory` registry directly yields zero adopter
5//! models. Injecting a [`DescriptorProvider`] lets an adopter-linked binary
6//! supply its own models. See [`run_with_provider`].
7
8use std::path::PathBuf;
9use std::process::ExitCode;
10
11use clap::{Parser, Subcommand};
12
13mod analyze;
14mod db;
15mod live;
16mod migrations;
17mod schema;
18mod verify;
19
20// Re-export CLI types so the thin `main.rs` shim and downstream crates
21// can reference them without duplicating definitions.
22#[allow(ambiguous_glob_reexports)]
23pub use crate::analyze::*;
24pub use crate::db::*;
25pub use crate::live::*;
26pub use crate::migrations::*;
27pub use crate::schema::*;
28pub use crate::verify::*;
29
30// Re-export proc macros so adopters write `djogi_cli::djogi_main!(…)` and
31// `djogi_cli::link_anchor!()` instead of depending on `djogi-macros` directly.
32// `link_anchor!` takes no arguments — it is a per-crate marker placed once in
33// each model crate's `lib.rs`.
34pub use djogi_macros::{djogi_main, link_anchor};
35
36// Re-export the boundary types so adopters/tests can name them without a
37// direct `djogi` dependency line.
38pub use djogi::migrate::{DescriptorProvider, InventoryDescriptorProvider};
39
40/// Print a support-boundary preflight error to stderr.
41/// Used by every CLI entry point that runs `check_postgres_version`.
42/// The "support boundary" prefix distinguishes infrastructure refusals
43/// (wrong PG version, missing extension) from policy refusals (localhost
44/// gate, production profile) and runtime failures (SQL error, network).
45pub fn print_support_boundary_error(subcommand: &str, err: &dyn std::fmt::Display) {
46 eprintln!("djogi {subcommand}: support boundary: {err}");
47}
48
49#[derive(Parser)]
50#[command(name = "djogi", about = "Djogi framework CLI")]
51pub struct Cli {
52 #[command(subcommand)]
53 pub command: TopCommand,
54}
55
56#[derive(Subcommand)]
57pub enum TopCommand {
58 /// Launch interactive Rhai shell.
59 Shell,
60 /// Database management.
61 Db {
62 #[command(subcommand)]
63 command: DbCommand,
64 },
65 /// Schema migration tooling .
66 Migrations {
67 #[command(subcommand)]
68 command: MigrationsCommand,
69 },
70 /// Compatibility alias for `djogi migrations`. See
71 /// `djogi migrations --help` for the full command tree.
72 /// Currently only `apply` is supported as an alias:
73 /// `djogi migrate apply` delegates to `djogi migrations apply`.
74 Migrate {
75 #[command(subcommand)]
76 command: MigrateCommand,
77 },
78 /// Live-migration operator surface — drives expand →
79 /// backfill → flip → contract sequences for `ExpandContract`-
80 /// classified deltas.
81 /// Requires PostgreSQL 18 or later.
82 Live {
83 #[command(subcommand)]
84 command: live::LiveCmd,
85 },
86 /// Render Markdown documentation from the descriptor inventory.
87 /// One file per registered model under `<output>/<app>/`, plus a
88 /// top-level `README.md` index. Output is byte-deterministic
89 /// against the same descriptor set.
90 Docs {
91 /// Output directory. Defaults to
92 /// `<workspace>/target/djogi-docs/`.
93 #[arg(long)]
94 output: Option<PathBuf>,
95 /// Workspace root override. Defaults to the current working
96 /// directory.
97 #[arg(long)]
98 workspace: Option<PathBuf>,
99 },
100 /// 6 — read-only HMAC cross-check of every
101 /// `migrations/<target>/<app>/schema_snapshot.json` against the
102 /// audit DB's `djogi_ddl_audit` ledger.
103 /// Requires PostgreSQL 18 or later — exits with code 2 if the
104 /// server is below the minimum.
105 /// Exit codes: `0` when every snapshot reports `OK` or `Skipped`
106 /// (audit table absent or no audit row yet), `1` on any mismatch
107 /// or runtime error (config / connect / I/O / key decode).
108 /// **Read-only.** Verify never issues `INSERT`, `UPDATE`,
109 /// `DELETE`, or DDL — the only SQL leaving the CLI is a
110 /// positional-bind `SELECT` against `djogi_ddl_audit`.
111 Verify {
112 /// Workspace root override. Defaults to the current working
113 /// directory.
114 #[arg(long)]
115 workspace: Option<PathBuf>,
116 },
117 /// 2 — JSON descriptor dump.
118 /// Emits a deterministic JSON document covering every model
119 /// registered via `inventory::submit!`. Use for agent
120 /// integration, CI assertions on schema drift, and
121 /// machine-readable handoffs to downstream codegen.
122 /// **Read-only.** Schema never opens a Postgres connection;
123 /// the inventory walk is fully in-process.
124 Schema {
125 /// Output format. `json` is the only value in v0.1.0;
126 /// `openapi` and `markdown` are reserved for .
127 #[arg(long, value_enum, default_value_t = SchemaFormat::Json)]
128 format: SchemaFormat,
129 /// Optional output file. Absent means stdout.
130 #[arg(long)]
131 output: Option<PathBuf>,
132 },
133 /// Partition / vacuum analysis for adopter
134 /// Postgres tables. Queries `pg_stat_user_tables` (and, when
135 /// installed, `pg_partman`) and recommends vacuum / partition
136 /// actions per the precedence laid out in [`analyze::Recommendation`].
137 /// Requires PostgreSQL 18 or later — exits with code 2 if the
138 /// server is below the minimum.
139 /// **Read-only.** Analyze issues only `SELECT` against system
140 /// catalogues; it never writes.
141 Analyze {
142 /// Output format. `human` (default) prints one line per table;
143 /// `json` emits a deterministic, sorted array of
144 /// `{table, recommendation}` objects suitable for CI
145 /// dashboards.
146 #[arg(long, value_enum, default_value_t = AnalyzeFormat::Human)]
147 format: AnalyzeFormat,
148 /// Dead-tuple ratio strictly above which `VacuumNeeded` fires.
149 /// Default `0.2` (20% bloat) — typical OLTP workloads tighten
150 /// this; warehouse workloads tend to leave it as-is. Validated
151 /// at parse time via [`parse_threshold_vacuum`]: rejects NaN /
152 /// infinity / values outside `[0.0, 1.0]` so silent
153 /// "never-fires" misconfigurations are impossible.
154 #[arg(long, default_value_t = 0.2, value_parser = parse_threshold_vacuum)]
155 threshold_vacuum: f64,
156 /// Live row count strictly above which an unpartitioned table
157 /// triggers `PartitionRecommended`. Default `10_000_000`. The
158 /// same threshold drives the per-partition row average that
159 /// fires `PartitionCountIncrease`.
160 #[arg(long, default_value_t = 10_000_000)]
161 threshold_partition_rows: i64,
162 /// Workspace root override. Defaults to the current working
163 /// directory. Mirrors `djogi verify --workspace`.
164 #[arg(long)]
165 workspace: Option<PathBuf>,
166 },
167}
168
169/// Output format for `djogi schema`. Mirrors
170/// [`schema::SchemaFormat`] so `clap::ValueEnum` lives at the CLI
171/// boundary and the `schema` module stays clap-free.
172#[derive(Debug, Clone, Copy, clap::ValueEnum)]
173pub enum SchemaFormat {
174 Json,
175}
176
177impl SchemaFormat {
178 fn into_schema(self) -> schema::SchemaFormat {
179 match self {
180 SchemaFormat::Json => schema::SchemaFormat::Json,
181 }
182 }
183}
184
185/// Output format for `djogi analyze` — clap-side mirror of
186/// [`analyze::AnalyzeFormat`].
187/// This enum exists only so `clap::ValueEnum` can derive the
188/// `--format human|json` parser without dragging the clap-derive
189/// dependency into the `analyze` module's pure-substrate header.
190/// Conversion to the canonical [`analyze::AnalyzeFormat`] happens at
191/// the dispatch site via [`Self::into_analyze`].
192#[derive(Debug, Clone, Copy, clap::ValueEnum)]
193pub enum AnalyzeFormat {
194 Human,
195 Json,
196}
197
198impl AnalyzeFormat {
199 /// Project the clap-side enum onto the canonical
200 /// [`analyze::AnalyzeFormat`] consumed by [`analyze::run`].
201 fn into_analyze(self) -> analyze::AnalyzeFormat {
202 match self {
203 AnalyzeFormat::Human => analyze::AnalyzeFormat::Human,
204 AnalyzeFormat::Json => analyze::AnalyzeFormat::Json,
205 }
206 }
207}
208
209/// Parse + validate `--threshold-vacuum` at the CLI boundary.
210/// Rejects three classes of nonsense input that plain `f64::parse`
211/// otherwise lets through:
212/// 1. **Non-finite values** (`NaN`, `inf`, `-inf`). Without this guard,
213/// `ratio > NaN` evaluates to `false` for every ratio, so
214/// `VacuumNeeded` would silently never fire — the worst kind of
215/// silent failure for a recommendation engine.
216/// 2. **Negative values.** A dead-tuple ratio is bounded in `[0.0, 1.0]`
217/// by definition (it's `dead / (live + dead)`), so a negative
218/// threshold is operator error, not a tuning choice.
219/// 3. **Values above `1.0`.** Same reasoning — no real
220/// `pg_stat_user_tables` row can produce a ratio above `1.0`, so a
221/// threshold above `1.0` would mean "VacuumNeeded never fires," which
222/// is again silent failure rather than legitimate configuration.
223/// Wired via clap's `value_parser` attribute so the rejection happens at
224/// argument-parsing time — operators see a clear error message and a
225/// non-zero exit, never a silently-misbehaving analyze run.
226fn parse_threshold_vacuum(s: &str) -> Result<f64, String> {
227 let v: f64 = s
228 .parse()
229 .map_err(|e: std::num::ParseFloatError| e.to_string())?;
230 if !v.is_finite() {
231 return Err(format!("threshold_vacuum must be finite (got {s})"));
232 }
233 if !(0.0..=1.0).contains(&v) {
234 return Err(format!("threshold_vacuum must be in [0.0, 1.0] (got {v})"));
235 }
236 Ok(v)
237}
238
239#[derive(Subcommand)]
240pub enum DbCommand {
241 /// Drop, recreate, and replay every committed migration against
242 /// the application database. **Triple-gated** — refuses unless
243 /// (a) `DATABASE_URL` resolves to localhost, (b)
244 /// `Djogi.toml::profile != "production"`, and (c) explicit
245 /// confirmation is supplied via `--yes` or the interactive
246 /// prompt. Logging databases (`crud_log`, `event_log`) are NOT
247 /// touched.
248 /// Requires PostgreSQL 18 or later — exits with code 2 if the
249 /// server is below the minimum.
250 /// Exit codes: 0 on success, 1 on error (config / network / SQL
251 /// / replay), 2 on gate refusal (not localhost, production
252 /// profile, missing `--yes`, below PG 18).
253 Reset {
254 /// Skip the interactive y/N prompt and proceed. Required for
255 /// non-interactive invocations (e.g. CI integration suites
256 /// that call `db reset` between tests).
257 #[arg(long, default_value_t = false)]
258 yes: bool,
259 /// Permit `db reset` to continue even when the live ledger's
260 /// checksums no longer match the current on-disk migration
261 /// files. Without this flag, checksum drift refuses before
262 /// the destructive drop / recreate step.
263 #[arg(long, default_value_t = false)]
264 allow_checksum_drift_reset: bool,
265 /// Maintenance database to connect to for the `DROP DATABASE`
266 /// then `CREATE DATABASE` round-trip. Defaults to `postgres`,
267 /// the conventional administrative DB present on every
268 /// cluster. Override only if the cluster has a different
269 /// administrative DB (e.g. AWS RDS uses `rdsadmin`).
270 #[arg(long, default_value = "postgres")]
271 maintenance_database: String,
272 /// Workspace root override.
273 #[arg(long)]
274 workspace: Option<PathBuf>,
275 },
276 /// Run operator-authored SQL seed files in `seeds/<database>/`.
277 /// Idempotent — re-runs skip seeds whose `V1:<sha256>` checksum
278 /// matches the `djogi_seed_runs` ledger; refuses on checksum
279 /// drift. Localhost-gated by default.
280 /// Requires PostgreSQL 18 or later — exits with code 2 if the
281 /// server is below the minimum.
282 /// `--database <name>` selects BOTH the seed directory and the
283 /// connection target. The CLI splices `<name>` into
284 /// `database.url`'s path component so seeds always land on the
285 /// matching DB; a malformed application URL refuses with exit
286 /// code 1.
287 /// Exit codes: 0 on success, 1 on error (config / network / SQL
288 /// / checksum drift / malformed URL), 2 on gate refusal
289 /// (non-localhost without `--allow-non-localhost`, below PG 18).
290 Seed {
291 /// Database name whose seeds directory should be run. The
292 /// runner walks `seeds/<database>/*.sql` in alphabetical
293 /// order.
294 #[arg(long, default_value = "main")]
295 database: String,
296 /// Allow seeds to run against a non-localhost database. The
297 /// gate is lighter than `db reset`'s — useful for CI
298 /// integration suites seeding a remote test database.
299 #[arg(long, default_value_t = false)]
300 allow_non_localhost: bool,
301 /// Workspace root override.
302 #[arg(long)]
303 workspace: Option<PathBuf>,
304 },
305 /// Drop orphaned `djogi_test_<uuid>` databases left over from
306 /// crashed `#[djogi_test]` runs (SIGKILL / OOM / panic-after-spawn
307 /// before teardown could fire). Triple-gated identical to
308 /// `db reset` — localhost (override via `--allow-non-localhost`),
309 /// non-production profile, explicit `--yes` (waived under
310 /// `--dry-run`).
311 /// Requires PostgreSQL 18 or later — exits with code 2 if the
312 /// server is below the minimum.
313 /// Exit codes: 0 on success, 1 on error (config / connect / SQL),
314 /// 2 on gate refusal (non-localhost, production profile, missing
315 /// `--yes` without `--dry-run`, below PG 18).
316 CleanupTestDbs {
317 /// List candidates without dropping. Skips the `--yes`
318 /// confirmation gate because no destructive side effect
319 /// occurs.
320 #[arg(long, default_value_t = false)]
321 dry_run: bool,
322 /// Skip the `--yes` confirmation gate. Required for
323 /// non-interactive invocations unless `--dry-run` is also set.
324 #[arg(long, default_value_t = false)]
325 yes: bool,
326 /// Maintenance database to connect to. Defaults to `postgres`,
327 /// the conventional administrative DB on every cluster.
328 /// Override only when the cluster uses a different admin DB
329 /// (e.g. AWS RDS uses `rdsadmin`).
330 #[arg(long, default_value = "postgres")]
331 maintenance_database: String,
332 /// Allow cleanup against a non-localhost cluster. Off by
333 /// default — the gate matches `db reset`'s localhost
334 /// requirement so destructive ops stay local unless the
335 /// operator explicitly opts out.
336 #[arg(long, default_value_t = false)]
337 allow_non_localhost: bool,
338 /// Workspace root override.
339 #[arg(long)]
340 workspace: Option<PathBuf>,
341 },
342}
343
344#[derive(Subcommand)]
345pub enum MigrateCommand {
346 /// Alias for `djogi migrations apply`. See
347 /// `djogi migrations apply --help` for full documentation.
348 /// Record pending migrations as applied in the ledger, optionally
349 /// without executing their SQL (`--fake`).
350 /// See `djogi migrations apply --help` for crash-recovery behavior,
351 /// including already-faked reruns and snapshot rebuilds.
352 Apply {
353 #[arg(long)]
354 workspace: Option<PathBuf>,
355
356 #[arg(long, default_value_t = false)]
357 fake: bool,
358
359 #[arg(long)]
360 reason: Option<String>,
361 },
362}
363
364#[derive(Subcommand)]
365pub enum MigrationsCommand {
366 /// Compose a new migration from descriptor inventory + last
367 /// snapshot.
368 Compose {
369 /// Operator-facing migration name. Sanitised down to a strict
370 /// identifier; defaults to `migration` when empty.
371 #[arg(long, default_value = "")]
372 name: String,
373 /// Allow destructive (drop) operations or tombstoned-app
374 /// migrations. Without this flag the compose path refuses
375 /// destructive deltas with a structural error.
376 #[arg(long, default_value_t = false)]
377 allow_destructive: bool,
378 /// Discard hand-edits to existing migration files. Without
379 /// this flag compose refuses to overwrite any up or down
380 /// migration file whose current bytes do NOT match what the
381 /// deterministic emitter would freshly produce — the
382 /// byte-equality check stands in for a checksum compare
383 /// because the emitter is deterministic (same inputs always
384 /// produce the same bytes). The check is purely byte-level;
385 /// it does not read the pending JSON's `checksum_up` field.
386 #[arg(long, default_value_t = false)]
387 force_overwrite: bool,
388 /// Workspace root override. Defaults to the current working
389 /// directory.
390 #[arg(long)]
391 workspace: Option<PathBuf>,
392 },
393 /// Print the current state of the migration ledger, grouped by
394 /// app. Read-only — does not acquire the workspace lock.
395 /// Requires PostgreSQL 18 or later.
396 Status {
397 /// Workspace root override (only used when reading
398 /// `Djogi.toml`).
399 #[arg(long)]
400 workspace: Option<PathBuf>,
401 },
402 /// Compare live database catalog against the schema snapshot.
403 /// Read-only — does not acquire the workspace lock or execute DDL.
404 /// Exits 0 if no error-level diagnostics are found. Exits 1 on
405 /// runtime errors (config / pool / SQL). Exits 2 if the Postgres
406 /// server is below version 18.
407 /// Use `--strict` to upgrade out-of-order migration warnings (D622)
408 /// to errors, causing verify to exit non-zero when the ledger
409 /// contains out-of-order applied rows.
410 Verify {
411 /// Workspace root override (only used when reading `Djogi.toml`).
412 #[arg(long)]
413 workspace: Option<PathBuf>,
414 /// Upgrade D622 out-of-order diagnostics from Warning to Error.
415 #[arg(long, default_value_t = false)]
416 strict: bool,
417 },
418 /// Reconcile local migration history with the ledger. Default
419 /// mode is a read-only diff between the on-disk SQL files and
420 /// the ledger. Attune is read-only by default — pass `--apply`
421 /// to commit ledger inserts / squash / parent-pointer writes.
422 /// `--record` updates the parent repo's recorded submodule
423 /// pointer to the resolved Git target after successful
424 /// attunement. `--squash --from <ver>` collapses local history
425 /// into a single migration (localhost + dev_mode + dev profile +
426 /// DJOGI_ENV gates).
427 /// Requires PostgreSQL 18 or later — exits with code 2 if the
428 /// server is below the minimum.
429 /// Exit codes: 0 on success, 1 on runtime error (config / network
430 /// / SQL / git), 2 on refusal (gate failure, arg validation,
431 /// below PG 18).
432 Attune {
433 /// Optional Git target to attune the local migration history
434 /// to — a local or remote commit / tag / branch. When
435 /// omitted, attune reconciles against the current on-disk
436 /// state. Resolution: tries local first, then `git fetch
437 /// --all` + retries on failure.
438 target: Option<String>,
439 /// Mutate the database / parent index. Without `--apply`,
440 /// attune is a dry-run — it scans, prints the diff, and
441 /// exits without inserting / deleting ledger rows or updating
442 /// the parent submodule pointer (per
443 /// `docs/spec/configuration.md` §14: "does not mutate the
444 /// database unless `--apply` is explicitly passed").
445 #[arg(long, default_value_t = false)]
446 apply: bool,
447 /// In Record mode (`--record-ledger`), insert ledger rows for
448 /// SQL files present on disk but absent from the ledger. With
449 /// a resolved `<target>` argument AND `--apply`, also update
450 /// the parent repo's recorded submodule pointer to the target
451 /// SHA.
452 #[arg(long, default_value_t = false)]
453 record: bool,
454 /// Activate Record mode — insert ledger rows for SQL files
455 /// present on disk but absent from the ledger. Distinct from
456 /// `--record` (which controls the parent submodule pointer).
457 /// Records the operator-supplied reason in `partial_apply_note`.
458 /// Does NOT execute SQL.
459 #[arg(
460 long = "record-ledger",
461 default_value_t = false,
462 conflicts_with = "squash"
463 )]
464 record_ledger: bool,
465 /// When `--record-ledger` is set, the rationale recorded on
466 /// every inserted ledger row's `partial_apply_note`.
467 #[arg(long, default_value = "operator asserted out-of-band apply")]
468 record_reason: String,
469 /// Coalesce every committed migration from `--from` to HEAD
470 /// into a single squashed migration. HISTORY REWRITE — gated
471 /// on localhost + dev profile + dev_mode + DJOGI_ENV.
472 #[arg(long, default_value_t = false)]
473 squash: bool,
474 /// Inclusive starting version for `--squash` (e.g.
475 /// `V20260101000000__init`).
476 #[arg(long)]
477 from: Option<String>,
478 /// After a successful squash, push the rewritten
479 /// `migrations/` submodule to its remote. Without this flag
480 /// the rewrite stays local. Squash NEVER auto-publishes.
481 #[arg(long, default_value_t = false)]
482 publish: bool,
483 /// Optional explicit app label to scope `--squash` to a
484 /// single bucket. Required when `--from` matches a version in
485 /// multiple buckets; auto-detected when the version is unique
486 /// to one bucket.
487 #[arg(long)]
488 app: Option<String>,
489 /// Workspace root override.
490 #[arg(long)]
491 workspace: Option<PathBuf>,
492 },
493 /// Apply all pending migrations in ledger order. This is the canonical spelling;
494 /// `djogi migrate apply` is a compatibility alias.
495 /// **Transaction semantics** are per-segment: transactional
496 /// segments roll back on error; non-transactional segments
497 /// autocommit and may leave partial progress.
498 /// **On crash** or unexpected termination, re-run
499 /// `djogi migrations apply`. For partial non-transactional
500 /// progress, use `djogi migrations repair resume-partial`.
501 /// **Existing-database adoption:** use `--fake` to mark pending
502 /// migrations as applied without executing their SQL. This is for
503 /// databases whose schema already exists (from a prior tool, manual
504 /// DDL, or restored backup). Use `djogi migrations verify` or
505 /// manual inspection to confirm the schema matches the target state
506 /// before faking. The `--fake` flag respects the same out-of-order
507 /// policy as real apply; if CI/prod policy is `Reject`, fake-apply
508 /// on an out-of-order version is also rejected.
509 /// For previewing pending work without executing it, use
510 /// `djogi migrations status`.
511 /// If the command is interrupted after recording a ledger row with
512 /// a terminal status (`applied`, `faked`, `baseline`), re-running
513 /// reports `VersionAlreadyApplied` (exit 2). For non-terminal
514 /// statuses (`failed`, `rolled_back`), the stale row is removed and
515 /// re-apply proceeds automatically. If the snapshot is missing or
516 /// stale, reconcile it with `djogi migrations attune` or
517 /// `repair snapshot-rebuild`.
518 Apply {
519 /// Workspace root override. Defaults to the current working
520 /// directory.
521 #[arg(long)]
522 workspace: Option<PathBuf>,
523
524 /// Record pending migrations as applied without executing
525 /// their SQL. For existing-database adoption only. Requires
526 /// `--reason`. Subject to the same out-of-order policy as real
527 /// apply; if CI/prod policy is `Reject`, fake-apply on an
528 /// out-of-order version is also rejected.
529 #[arg(long, default_value_t = false)]
530 fake: bool,
531
532 /// Reason for faking these migrations. Required when `--fake`
533 /// is set. Persisted to the ledger's audit trail so future
534 /// inspections can understand why this version was recorded
535 /// without SQL execution. Has no effect on normal (non-fake)
536 /// apply.
537 #[arg(long)]
538 reason: Option<String>,
539 },
540 /// Operator-confirmed repair flows for ledger drift, partial
541 /// applies, and missing snapshots. Every subcommand requires
542 /// explicit confirmation — invoking the CLI subcommand IS the
543 /// operator acknowledgment.
544 Repair {
545 /// The specific repair operation to perform.
546 #[command(subcommand)]
547 command: RepairSubcommand,
548 },
549 /// Project the live database schema into a baseline ledger row and
550 /// snapshot. Use for existing databases being adopted under Djogi's
551 /// migration ledger, where the schema already exists and compose +
552 /// apply cannot run on a populated database without a starting point.
553 /// Projects the live catalog into a single `baseline` ledger row
554 /// (no SQL runs against user tables) and writes the projected
555 /// snapshot so future migrations diff against the real DB state.
556 /// Invoking the subcommand IS the operator acknowledgment.
557 /// Requires PostgreSQL 18 or later — exits with code 2 if the
558 /// server is below the minimum.
559 /// Exit codes: 0 on success, 1 on runtime error (config / network /
560 /// SQL / projection failure), 2 on refusal (empty `--reason`, duplicate
561 /// version, unresolvable database URL, snapshot-persist failure after
562 /// ledger insert, session-pinning correctness failure, or below PG 18).
563 Baseline {
564 /// Version label for the baseline ledger row (e.g.
565 /// `V00000000000000__baseline`). Must be unique in the ledger.
566 version: String,
567 /// One-line description stored in the ledger row.
568 #[arg(long, default_value = "existing database schema baseline")]
569 description: String,
570 /// Required non-empty reason recorded in the baseline note
571 /// (audit trail entry).
572 #[arg(long)]
573 reason: String,
574 /// App label for the migration bucket. Defaults to the global
575 /// bucket (empty string) when not specified.
576 #[arg(long)]
577 app: Option<String>,
578 /// Database name. Defaults to `main` if not specified.
579 #[arg(long)]
580 database: Option<String>,
581 /// Workspace root override.
582 #[arg(long)]
583 workspace: Option<PathBuf>,
584 },
585}
586
587/// `djogi migrations repair <subcommand>` — the four operator-confirmed
588/// repair flows.
589/// Each variant maps 1:1 onto a `djogi::migrate::repair::*` library
590/// function. Invoking the subcommand IS the operator acknowledgment;
591/// there is no separate `--confirm` flag. Every flow pins one Postgres
592/// session, takes the per-bucket advisory lock, and holds the workspace
593/// file lock for its duration.
594/// Exit codes (shared across all four): `0` success, `1`
595/// runtime/I/O error (retryable), `2` refusal or structural mismatch
596/// (operator must intervene).
597#[derive(Clone, Subcommand)]
598pub enum RepairSubcommand {
599 /// Update ledger checksum when migration file content changed
600 /// but the row was already applied.
601 ChecksumDrift {
602 /// Migration version (e.g. `V20260101000000__add_users`).
603 version: String,
604 /// App label for the migration bucket. Defaults to the global
605 /// bucket (empty string) when not specified.
606 #[arg(long)]
607 app: Option<String>,
608 /// Database name. Defaults to `main` if not specified.
609 #[arg(long)]
610 database: Option<String>,
611 /// New `checksum_up` value (SHA-256 hex). If omitted, computed
612 /// from the committed up SQL file.
613 #[arg(long)]
614 checksum_up: Option<String>,
615 /// New `checksum_down` value (SHA-256 hex). If omitted and
616 /// down file exists, computed from committed down SQL file.
617 /// Missing down file is a no-op; other read errors abort.
618 #[arg(long)]
619 checksum_down: Option<String>,
620 /// Workspace root override.
621 #[arg(long)]
622 workspace: Option<PathBuf>,
623 },
624
625 /// Resolve a partial-apply row by rewriting its status to one of
626 /// `rolled_back`, `faked`, or `applied`. Does NOT execute SQL.
627 PartialApply {
628 /// Migration version to repair.
629 version: String,
630 /// Resolution: `rolled-back`, `faked`, or `applied`.
631 #[arg(value_enum)]
632 resolution: PartialApplyResolutionCli,
633 /// Operator note persisted in the ledger row's
634 /// `partial_apply_note` column.
635 #[arg(long, default_value = "operator resolved partial apply via CLI")]
636 note: String,
637 /// App label (empty string for global bucket).
638 #[arg(long)]
639 app: Option<String>,
640 /// Database name. Defaults to `main` if not specified.
641 #[arg(long)]
642 database: Option<String>,
643 /// Workspace root override.
644 #[arg(long)]
645 workspace: Option<PathBuf>,
646 },
647
648 /// Resume an interrupted non-transactional apply by re-loading
649 /// the committed replay plan and executing remaining steps.
650 ResumePartial {
651 /// Migration version to resume.
652 version: String,
653 /// App label (empty string for global bucket).
654 #[arg(long)]
655 app: Option<String>,
656 /// Database name. Defaults to `main` if not specified.
657 #[arg(long)]
658 database: Option<String>,
659 /// Workspace root override.
660 #[arg(long)]
661 workspace: Option<PathBuf>,
662 },
663
664 /// Rebuild the schema snapshot for a bucket by walking the
665 /// ledger and re-projecting from live database state.
666 SnapshotRebuild {
667 /// App label (empty string for global bucket).
668 #[arg(long)]
669 app: Option<String>,
670 /// Database name. Defaults to `main` if not specified.
671 #[arg(long)]
672 database: Option<String>,
673 /// Explicit snapshot path override. If omitted, derived from
674 /// `migrations/<database>/<app>/schema_snapshot.json`.
675 #[arg(long)]
676 snapshot_path: Option<PathBuf>,
677 /// Workspace root override.
678 #[arg(long)]
679 workspace: Option<PathBuf>,
680 },
681}
682
683/// CLI-side mirror of [`djogi::migrate::PartialApplyResolution`] for the
684/// `repair partial-apply` resolution argument.
685/// This enum exists only so `clap::ValueEnum` can parse
686/// `rolled-back | faked | applied` at the CLI boundary without the
687/// library enum carrying a clap-derive dependency. Conversion to the
688/// canonical [`djogi::migrate::PartialApplyResolution`] happens via the
689/// `From` impl in the `migrations` module.
690#[derive(clap::ValueEnum, Clone, Debug)]
691pub enum PartialApplyResolutionCli {
692 RolledBack,
693 Faked,
694 Applied,
695}
696
697// ── Entrypoints ───────────────────────────────────────────────────────────
698
699/// Run the CLI by parsing arguments from `std::env::args_os()`.
700/// This is the entry point used by the published standalone `djogi`
701/// binary. It reads the global link-time [`inventory`] registry via
702/// [`djogi::migrate::InventoryDescriptorProvider`].
703pub fn run_from_env() -> ExitCode {
704 let cli = match Cli::try_parse_from(std::env::args_os()) {
705 Ok(c) => c,
706 Err(e) => {
707 let _ = e.print();
708 return ExitCode::from(if e.use_stderr() { 2 } else { 0 });
709 }
710 };
711 dispatch_command(
712 &cli.command,
713 &djogi::migrate::InventoryDescriptorProvider::new(),
714 )
715}
716
717/// Run the CLI with an explicit argument iterable. Useful for testing and
718/// embedding.
719/// Accepts any `IntoIterator<Item = T>` where `T: Into<OsString> + Clone`,
720/// matching the bound of [`clap::Parser::try_parse_from`]. In practice,
721/// arrays of `&str` (e.g. `["djogi", "migrations", "compose"]`) and
722/// `Vec<String>` both satisfy this bound.
723/// Falls back to [`djogi::migrate::InventoryDescriptorProvider`] for
724/// descriptors.
725pub fn run_with_args<I, T>(args: I) -> ExitCode
726where
727 I: IntoIterator<Item = T>,
728 T: Into<std::ffi::OsString> + Clone,
729{
730 let cli = match Cli::try_parse_from(args) {
731 Ok(c) => c,
732 Err(e) => {
733 // Print the clap error / `--help` / `--version` text before
734 // returning, matching `run_from_env`. Without this, parse
735 // errors and `--help` would be silent.
736 let _ = e.print();
737 return ExitCode::from(if e.use_stderr() { 2 } else { 0 });
738 }
739 };
740 dispatch_command(
741 &cli.command,
742 &djogi::migrate::InventoryDescriptorProvider::new(),
743 )
744}
745
746/// Run the CLI with an explicit argument iterable and a [`DescriptorProvider`].
747/// Accepts any `IntoIterator<Item = T>` where `T: Into<OsString> + Clone`,
748/// matching the bound of [`clap::Parser::try_parse_from`].
749/// Adopter-linked binaries pass their own provider so descriptor-dependent
750/// commands (`compose`, `verify`, `schema`, `docs`) see the adopter's
751/// models instead of an empty inventory.
752pub fn run_with_provider<I, T>(
753 args: I,
754 provider: &dyn djogi::migrate::DescriptorProvider,
755) -> ExitCode
756where
757 I: IntoIterator<Item = T>,
758 T: Into<std::ffi::OsString> + Clone,
759{
760 let cli = match Cli::try_parse_from(args) {
761 Ok(c) => c,
762 Err(e) => {
763 // Print the clap error / `--help` / `--version` text before
764 // returning, matching `run_from_env`.
765 let _ = e.print();
766 return ExitCode::from(if e.use_stderr() { 2 } else { 0 });
767 }
768 };
769 dispatch_command(&cli.command, provider)
770}
771
772// ── Dispatch ──────────────────────────────────────────────────────────────
773
774fn dispatch_command(
775 command: &TopCommand,
776 provider: &dyn djogi::migrate::DescriptorProvider,
777) -> ExitCode {
778 match command {
779 TopCommand::Shell => {
780 eprintln!("djogi shell: not yet implemented");
781 ExitCode::from(0)
782 }
783 TopCommand::Db { command } => match command {
784 DbCommand::Reset {
785 yes,
786 allow_checksum_drift_reset,
787 maintenance_database,
788 workspace,
789 } => db::reset_cmd(
790 *yes,
791 *allow_checksum_drift_reset,
792 maintenance_database.clone(),
793 workspace.clone(),
794 ),
795 DbCommand::Seed {
796 database,
797 allow_non_localhost,
798 workspace,
799 } => db::seed_cmd(database.clone(), *allow_non_localhost, workspace.clone()),
800 DbCommand::CleanupTestDbs {
801 dry_run,
802 yes,
803 maintenance_database,
804 allow_non_localhost,
805 workspace,
806 } => db::cleanup_test_dbs_cmd(
807 *dry_run,
808 *yes,
809 maintenance_database.clone(),
810 *allow_non_localhost,
811 workspace.clone(),
812 ),
813 },
814 TopCommand::Docs { output, workspace } => {
815 if provider.models().is_empty() {
816 print_zero_descriptor_diagnostic("docs");
817 return ExitCode::from(2);
818 }
819 db::docs_cmd(provider, output.clone(), workspace.clone())
820 }
821 TopCommand::Live { command } => live::dispatch(command.clone()),
822 TopCommand::Verify { workspace } => {
823 let runtime = match tokio::runtime::Builder::new_current_thread()
824 .enable_all()
825 .build()
826 {
827 Ok(r) => r,
828 Err(e) => {
829 eprintln!("djogi verify: tokio runtime: {e}");
830 return ExitCode::from(1);
831 }
832 };
833 match runtime.block_on(verify::run(workspace.clone())) {
834 Ok(code) => code,
835 Err(e) => {
836 eprintln!("djogi verify: {e}");
837 ExitCode::from(1)
838 }
839 }
840 }
841 TopCommand::Schema { format, output } => {
842 let models: Vec<&'static djogi::descriptor::ModelDescriptor> = provider.models();
843 if models.is_empty() {
844 print_zero_descriptor_diagnostic("schema");
845 return ExitCode::from(2);
846 }
847 match schema::run(format.into_schema(), &models, output.clone()) {
848 Ok(()) => ExitCode::SUCCESS,
849 Err(e) => {
850 eprintln!("djogi schema: {e}");
851 ExitCode::from(1)
852 }
853 }
854 }
855 TopCommand::Analyze {
856 format,
857 threshold_vacuum,
858 threshold_partition_rows,
859 workspace,
860 } => {
861 let runtime = match tokio::runtime::Builder::new_current_thread()
862 .enable_all()
863 .build()
864 {
865 Ok(r) => r,
866 Err(e) => {
867 eprintln!("djogi analyze: tokio runtime: {e}");
868 return ExitCode::from(1);
869 }
870 };
871 match runtime.block_on(analyze::run(
872 workspace.clone(),
873 format.into_analyze(),
874 *threshold_vacuum,
875 *threshold_partition_rows,
876 )) {
877 Ok(()) => ExitCode::SUCCESS,
878 Err(e) => {
879 eprintln!("djogi analyze: {e}");
880 ExitCode::from(1)
881 }
882 }
883 }
884 TopCommand::Migrations { command } => match command {
885 MigrationsCommand::Compose {
886 name,
887 allow_destructive,
888 force_overwrite,
889 workspace,
890 } => {
891 if provider.models().is_empty() {
892 print_zero_descriptor_diagnostic("migrations compose");
893 return ExitCode::from(2);
894 }
895 migrations::compose_cmd(
896 provider,
897 name.as_str(),
898 *allow_destructive,
899 *force_overwrite,
900 workspace.clone(),
901 )
902 }
903 MigrationsCommand::Status { workspace } => migrations::status_cmd(workspace.clone()),
904 MigrationsCommand::Verify { workspace, strict } => {
905 migrations::verify_cmd(provider, workspace.clone(), *strict)
906 }
907 MigrationsCommand::Attune {
908 target,
909 apply,
910 record,
911 record_ledger,
912 record_reason,
913 squash,
914 from,
915 publish,
916 app,
917 workspace,
918 } => migrations::attune_cmd(
919 target.as_deref(),
920 *apply,
921 *record,
922 *record_ledger,
923 record_reason.as_str(),
924 *squash,
925 from.as_deref(),
926 *publish,
927 app.as_deref(),
928 workspace.clone(),
929 ),
930 MigrationsCommand::Apply {
931 workspace,
932 fake,
933 reason,
934 } => migrations::apply_cmd(workspace.clone(), *fake, reason.clone()),
935 MigrationsCommand::Repair { command } => migrations::repair_cmd(command.clone()),
936 MigrationsCommand::Baseline {
937 version,
938 description,
939 reason,
940 app,
941 database,
942 workspace,
943 } => migrations::baseline_cmd(
944 version,
945 description,
946 reason,
947 app.as_deref(),
948 database.as_deref(),
949 workspace.clone(),
950 ),
951 },
952 TopCommand::Migrate { command } => match command {
953 MigrateCommand::Apply {
954 workspace,
955 fake,
956 reason,
957 } => migrations::apply_cmd(workspace.clone(), *fake, reason.clone()),
958 },
959 }
960}
961
962/// Print the §5.6 dual-cause diagnostic when a descriptor-dependent
963/// command (`compose` / `verify` / `schema` / `docs`) resolves zero model
964/// descriptors, and exits the command with code `2` (refusal — the
965/// command refuses because it cannot see the schema it needs).
966/// The message is dual-cause because zero descriptors has two distinct
967/// causes the operator must be able to tell apart:
968/// 1. they ran the *standalone published* `djogi`, which links no
969/// application models (build an adopter-linked `djogi` and run from it;
970/// the standalone binary can still `migrations apply`); or
971/// 2. this *is* their adopter-linked `djogi` but the linker dropped an
972/// unreferenced model crate (ensure every `#[derive(Model)]` crate is
973/// referenced via `link_models` / `djogi_main!`).
974/// The first line is kept verbatim in sync with the troubleshooting
975/// anchor in `docs/guide/adopter-cli.md` ("no djogi models are registered
976/// in this binary") so an operator who searches the message lands on the
977/// guide section that explains it.
978/// `command` is the failing command name (e.g. `"migrations compose"`),
979/// echoed so the operator knows which invocation refused. The single
980/// emitter feeds `compose`, `verify`, `schema`, and `docs`, so one message
981/// covers all four.
982pub(crate) fn print_zero_descriptor_diagnostic(command: &str) {
983 eprintln!("error: no djogi models are registered in this binary (djogi {command}).");
984 eprintln!();
985 eprintln!("Descriptor-dependent commands (compose, verify, schema, docs) require a");
986 eprintln!("djogi binary linked with your model crates.");
987 eprintln!();
988 eprintln!(" • If you ran the standalone published `djogi`: that binary links no");
989 eprintln!(" application models. Build an adopter-linked `djogi` (see the adopter");
990 eprintln!(" CLI guide: docs/guide/adopter-cli.md) and run the command from it.");
991 eprintln!(" The standalone binary can still run `djogi migrations apply` against");
992 eprintln!(" already-composed pending artifacts.");
993 eprintln!();
994 eprintln!(" • If this IS your adopter-linked `djogi`: ensure your bin references");
995 eprintln!(" every crate that defines `#[derive(Model)]` (link_models / djogi_main!),");
996 eprintln!(" or the linker may have dropped an unreferenced model crate.");
997}
998
999#[cfg(test)]
1000mod tests {
1001 //! CLI-level argument-parsing tests. These exercise the `value_parser`
1002 //! attached to `--threshold-vacuum` directly; the goal is to pin the
1003 //! contract that nonsense input fails at parse time rather than
1004 //! silently producing a recommendation engine that "never fires."
1005
1006 use clap::Parser as _;
1007
1008 use std::path::PathBuf;
1009
1010 use super::{
1011 Cli, DbCommand, MigrateCommand, MigrationsCommand, PartialApplyResolutionCli,
1012 RepairSubcommand, TopCommand, parse_threshold_vacuum,
1013 };
1014
1015 #[test]
1016 fn parse_threshold_vacuum_accepts_valid_values() {
1017 assert_eq!(parse_threshold_vacuum("0.0").unwrap(), 0.0);
1018 assert_eq!(parse_threshold_vacuum("0.2").unwrap(), 0.2);
1019 assert_eq!(parse_threshold_vacuum("1.0").unwrap(), 1.0);
1020 // Boundary check: strictly inside the closed interval.
1021 assert_eq!(parse_threshold_vacuum("0.5").unwrap(), 0.5);
1022 }
1023
1024 #[test]
1025 fn parse_threshold_vacuum_rejects_nan_inf_and_out_of_range() {
1026 // NaN — the entire reason this validator exists. `ratio > NaN`
1027 // is always false, so silent acceptance would mean VacuumNeeded
1028 // never fires, ever.
1029 let err = parse_threshold_vacuum("NaN").unwrap_err();
1030 assert!(err.contains("finite"), "err: {err}");
1031
1032 // Positive infinity — same silent-failure mode.
1033 let err = parse_threshold_vacuum("inf").unwrap_err();
1034 assert!(err.contains("finite"), "err: {err}");
1035
1036 // Negative infinity.
1037 let err = parse_threshold_vacuum("-inf").unwrap_err();
1038 assert!(err.contains("finite"), "err: {err}");
1039
1040 // Negative finite — outside `[0.0, 1.0]`.
1041 let err = parse_threshold_vacuum("-0.1").unwrap_err();
1042 assert!(err.contains("[0.0, 1.0]"), "err: {err}");
1043
1044 // Above 1.0 — outside `[0.0, 1.0]`.
1045 let err = parse_threshold_vacuum("1.5").unwrap_err();
1046 assert!(err.contains("[0.0, 1.0]"), "err: {err}");
1047
1048 // Garbage — propagates the underlying ParseFloatError message.
1049 assert!(parse_threshold_vacuum("not-a-number").is_err());
1050 }
1051
1052 #[test]
1053 fn db_reset_parses_allow_checksum_drift_reset_flag() {
1054 let cli = Cli::try_parse_from([
1055 "djogi",
1056 "db",
1057 "reset",
1058 "--yes",
1059 "--allow-checksum-drift-reset",
1060 ])
1061 .expect("flag should parse");
1062
1063 match cli.command {
1064 TopCommand::Db {
1065 command:
1066 DbCommand::Reset {
1067 yes,
1068 allow_checksum_drift_reset,
1069 ..
1070 },
1071 } => {
1072 assert!(yes, "--yes should parse through");
1073 assert!(
1074 allow_checksum_drift_reset,
1075 "checksum-drift override flag should parse through"
1076 );
1077 }
1078 _ => panic!("expected db reset command"),
1079 }
1080 }
1081
1082 #[test]
1083 fn migrate_apply_alias_parses() {
1084 let cli = Cli::try_parse_from(["djogi", "migrate", "apply"])
1085 .expect("migrate apply should parse as alias");
1086
1087 match cli.command {
1088 TopCommand::Migrate {
1089 command: MigrateCommand::Apply { .. },
1090 } => {}
1091 _ => panic!("expected migrate apply command"),
1092 }
1093 }
1094
1095 #[test]
1096 fn canonical_migrations_apply_parses() {
1097 let cli = Cli::try_parse_from(["djogi", "migrations", "apply"])
1098 .expect("canonical migrations apply should parse");
1099
1100 match cli.command {
1101 TopCommand::Migrations {
1102 command: MigrationsCommand::Apply { .. },
1103 } => {}
1104 _ => panic!("expected migrations apply command"),
1105 }
1106 }
1107
1108 #[test]
1109 fn canonical_migrations_status_still_parses() {
1110 let cli = Cli::try_parse_from(["djogi", "migrations", "status"])
1111 .expect("canonical migrations status should parse");
1112
1113 match cli.command {
1114 TopCommand::Migrations {
1115 command: MigrationsCommand::Status { .. },
1116 } => {}
1117 _ => panic!("expected migrations status command"),
1118 }
1119 }
1120
1121 #[test]
1122 fn migrations_verify_parses_with_defaults() {
1123 let cli = Cli::try_parse_from(["djogi", "migrations", "verify"])
1124 .expect("migrations verify should parse with no flags");
1125
1126 match cli.command {
1127 TopCommand::Migrations {
1128 command: MigrationsCommand::Verify { workspace, strict },
1129 } => {
1130 assert!(workspace.is_none());
1131 assert!(!strict);
1132 }
1133 _ => panic!("expected migrations verify command"),
1134 }
1135 }
1136
1137 #[test]
1138 fn migrations_verify_parses_with_strict() {
1139 let cli = Cli::try_parse_from(["djogi", "migrations", "verify", "--strict"])
1140 .expect("migrations verify --strict should parse");
1141
1142 match cli.command {
1143 TopCommand::Migrations {
1144 command: MigrationsCommand::Verify { strict, .. },
1145 } => {
1146 assert!(strict);
1147 }
1148 _ => panic!("expected migrations verify command"),
1149 }
1150 }
1151
1152 #[test]
1153 fn migrations_verify_parses_with_workspace() {
1154 let cli = Cli::try_parse_from([
1155 "djogi",
1156 "migrations",
1157 "verify",
1158 "--workspace",
1159 "/custom/path",
1160 ])
1161 .expect("migrations verify --workspace should parse");
1162
1163 match cli.command {
1164 TopCommand::Migrations {
1165 command: MigrationsCommand::Verify { workspace, .. },
1166 } => {
1167 assert_eq!(workspace, Some(PathBuf::from("/custom/path")));
1168 }
1169 _ => panic!("expected migrations verify command"),
1170 }
1171 }
1172
1173 // ── repair subcommand argument parsing ─────────────────────────────────
1174
1175 #[test]
1176 fn parse_repair_checksum_drift_accepts_required_args() {
1177 let cli = Cli::parse_from([
1178 "djogi",
1179 "migrations",
1180 "repair",
1181 "checksum-drift",
1182 "V20260101000000__test",
1183 "--checksum-up",
1184 "V1:aaaa",
1185 ]);
1186 assert!(matches!(cli.command, TopCommand::Migrations { .. }));
1187 }
1188
1189 #[test]
1190 fn parse_repair_checksum_drift_rejects_missing_version() {
1191 let result = Cli::try_parse_from(["djogi", "migrations", "repair", "checksum-drift"]);
1192 assert!(result.is_err(), "must require version argument");
1193 }
1194
1195 #[test]
1196 fn parse_repair_partial_apply_accepts_resolution_values() {
1197 for resolution in ["rolled-back", "faked", "applied"] {
1198 let cli = Cli::parse_from([
1199 "djogi",
1200 "migrations",
1201 "repair",
1202 "partial-apply",
1203 "V20260101000000__test",
1204 resolution,
1205 ]);
1206 assert!(
1207 matches!(cli.command, TopCommand::Migrations { .. }),
1208 "resolution={resolution}"
1209 );
1210 }
1211 }
1212
1213 #[test]
1214 fn parse_repair_partial_apply_rejects_invalid_resolution() {
1215 let result = Cli::try_parse_from([
1216 "djogi",
1217 "migrations",
1218 "repair",
1219 "partial-apply",
1220 "V20260101000000__test",
1221 "invalid-resolution",
1222 ]);
1223 assert!(result.is_err(), "must reject unknown resolution");
1224 }
1225
1226 #[test]
1227 fn parse_repair_resume_partial_accepts_version() {
1228 let cli = Cli::parse_from([
1229 "djogi",
1230 "migrations",
1231 "repair",
1232 "resume-partial",
1233 "V20260101000000__test",
1234 ]);
1235 assert!(matches!(cli.command, TopCommand::Migrations { .. }));
1236 }
1237
1238 #[test]
1239 fn parse_repair_snapshot_rebuild_accepts_flags() {
1240 let cli = Cli::parse_from([
1241 "djogi",
1242 "migrations",
1243 "repair",
1244 "snapshot-rebuild",
1245 "--app",
1246 "myapp",
1247 ]);
1248 assert!(matches!(cli.command, TopCommand::Migrations { .. }));
1249 }
1250
1251 // Field-binding destructuring tests — one per subcommand that carries
1252 // arguments. The outer-shape `matches!(..)` tests above prove the
1253 // variant is reached; these prove the named clap fields actually bind
1254 // to the supplied values (catching a `#[arg(long)]` typo or a
1255 // positional/flag mix-up that an outer-shape assertion would miss).
1256
1257 #[test]
1258 fn parse_repair_checksum_drift_binds_version_and_checksum_up() {
1259 let cli = Cli::parse_from([
1260 "djogi",
1261 "migrations",
1262 "repair",
1263 "checksum-drift",
1264 "V20260101000000__add_users",
1265 "--checksum-up",
1266 "V1:aaaa",
1267 ]);
1268 if let TopCommand::Migrations {
1269 command: MigrationsCommand::Repair { command },
1270 } = cli.command
1271 {
1272 if let RepairSubcommand::ChecksumDrift {
1273 version,
1274 checksum_up,
1275 ..
1276 } = command
1277 {
1278 assert_eq!(version, "V20260101000000__add_users");
1279 assert_eq!(checksum_up.as_deref(), Some("V1:aaaa"));
1280 } else {
1281 panic!("wrong variant");
1282 }
1283 } else {
1284 panic!("wrong command");
1285 }
1286 }
1287
1288 #[test]
1289 fn parse_repair_partial_apply_binds_resolution_and_note() {
1290 let cli = Cli::parse_from([
1291 "djogi",
1292 "migrations",
1293 "repair",
1294 "partial-apply",
1295 "V20260101000000__add_users",
1296 "rolled-back",
1297 "--note",
1298 "reverted by hot-fix",
1299 ]);
1300 if let TopCommand::Migrations {
1301 command: MigrationsCommand::Repair { command },
1302 } = cli.command
1303 {
1304 if let RepairSubcommand::PartialApply {
1305 version,
1306 resolution,
1307 note,
1308 ..
1309 } = command
1310 {
1311 assert_eq!(version, "V20260101000000__add_users");
1312 assert!(matches!(resolution, PartialApplyResolutionCli::RolledBack));
1313 assert_eq!(note, "reverted by hot-fix");
1314 } else {
1315 panic!("wrong variant");
1316 }
1317 } else {
1318 panic!("wrong command");
1319 }
1320 }
1321
1322 #[test]
1323 fn parse_repair_snapshot_rebuild_binds_app_and_database() {
1324 let cli = Cli::parse_from([
1325 "djogi",
1326 "migrations",
1327 "repair",
1328 "snapshot-rebuild",
1329 "--app",
1330 "billing",
1331 "--database",
1332 "analytics",
1333 ]);
1334 if let TopCommand::Migrations {
1335 command: MigrationsCommand::Repair { command },
1336 } = cli.command
1337 {
1338 if let RepairSubcommand::SnapshotRebuild { app, database, .. } = command {
1339 assert_eq!(app.as_deref(), Some("billing"));
1340 assert_eq!(database.as_deref(), Some("analytics"));
1341 } else {
1342 panic!("wrong variant");
1343 }
1344 } else {
1345 panic!("wrong command");
1346 }
1347 }
1348
1349 // ── baseline subcommand argument parsing ───────────────────────────────
1350
1351 /// Extract the `MigrationsCommand::Baseline` variant from a parsed
1352 /// `Cli`, panicking on any other shape. Used by the baseline
1353 /// field-binding tests below so each test reads as a flat sequence
1354 /// of field assertions rather than nested `if let`s.
1355 fn baseline_command(cli: Cli) -> MigrationsCommand {
1356 match cli.command {
1357 TopCommand::Migrations {
1358 command: command @ MigrationsCommand::Baseline { .. },
1359 } => command,
1360 _ => panic!("expected migrations baseline command"),
1361 }
1362 }
1363
1364 #[test]
1365 fn parse_baseline_accepts_required_args() {
1366 let cli = Cli::try_parse_from([
1367 "djogi",
1368 "migrations",
1369 "baseline",
1370 "V00000000000000__baseline",
1371 "--reason",
1372 "schema pre-exists from prior tooling",
1373 ])
1374 .unwrap();
1375 let MigrationsCommand::Baseline {
1376 version,
1377 reason,
1378 description,
1379 app,
1380 database,
1381 ..
1382 } = baseline_command(cli)
1383 else {
1384 panic!("expected Baseline");
1385 };
1386 assert_eq!(version, "V00000000000000__baseline");
1387 assert_eq!(reason, "schema pre-exists from prior tooling");
1388 assert_eq!(description, "existing database schema baseline");
1389 assert!(app.is_none());
1390 assert!(database.is_none());
1391 }
1392
1393 #[test]
1394 fn parse_baseline_rejects_missing_version() {
1395 let result = Cli::try_parse_from(["djogi", "migrations", "baseline", "--reason", "test"]);
1396 assert!(
1397 result.is_err(),
1398 "baseline without version positional should fail"
1399 );
1400 }
1401
1402 #[test]
1403 fn parse_baseline_rejects_missing_reason() {
1404 let result = Cli::try_parse_from([
1405 "djogi",
1406 "migrations",
1407 "baseline",
1408 "V00000000000000__baseline",
1409 ]);
1410 assert!(result.is_err(), "baseline without --reason should fail");
1411 }
1412
1413 #[test]
1414 fn parse_baseline_accepts_optional_flags() {
1415 let cli = Cli::try_parse_from([
1416 "djogi",
1417 "migrations",
1418 "baseline",
1419 "V00000000000000__baseline",
1420 "--reason",
1421 "existing schema",
1422 "--description",
1423 "custom description",
1424 "--app",
1425 "billing",
1426 "--database",
1427 "crud_log",
1428 ])
1429 .unwrap();
1430 let MigrationsCommand::Baseline {
1431 version,
1432 reason,
1433 description,
1434 app,
1435 database,
1436 ..
1437 } = baseline_command(cli)
1438 else {
1439 panic!("expected Baseline");
1440 };
1441 assert_eq!(version, "V00000000000000__baseline");
1442 assert_eq!(reason, "existing schema");
1443 assert_eq!(description, "custom description");
1444 assert_eq!(app.as_deref(), Some("billing"));
1445 assert_eq!(database.as_deref(), Some("crud_log"));
1446 }
1447}