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