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