Skip to main content

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}