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    /// **Drift pre-flight:** real apply verifies the live catalog
531    /// against the recorded `schema_snapshot.json` before executing
532    /// SQL and refuses with exit `2` when error-severity drift is
533    /// found. The check self-skips only when the bucket has never
534    /// been applied; a missing snapshot on a previously-applied
535    /// bucket is itself a refusal (exit `2`) and should be repaired
536    /// with `djogi migrations repair snapshot-rebuild` or restored
537    /// from version control. `--fake` neither runs the pre-flight nor
538    /// reads the snapshot file.
539    /// **Node identity:** for operations that execute SQL, supply
540    /// `--node-id <id>` (explicit cluster node) or
541    /// `--single-node-dev` (dev mode, binds node 1). Mutually exclusive.
542    /// Falls back to `HEER_NODE_ID` env var when neither flag is set.
543    /// Refuses without identity for non-dev operations (exit 2).
544    /// For previewing pending work without executing it, use
545    /// `djogi migrations status`.
546    /// If the command is interrupted after recording a ledger row with
547    /// a terminal status (`applied`, `faked`, `baseline`), re-running
548    /// reports `VersionAlreadyApplied` (exit 2). For non-terminal
549    /// statuses (`failed`, `rolled_back`), the stale row is removed and
550    /// re-apply proceeds automatically. If the snapshot is missing or
551    /// stale, reconcile it with `djogi migrations attune` or
552    /// `repair snapshot-rebuild`.
553    Apply {
554        /// Workspace root override. Defaults to the current working
555        /// directory.
556        #[arg(long)]
557        workspace: Option<PathBuf>,
558
559        /// Record pending migrations as applied without executing
560        /// their SQL. For existing-database adoption only. Requires
561        /// `--reason`. Subject to the same out-of-order policy as real
562        /// apply; if CI/prod policy is `Reject`, fake-apply on an
563        /// out-of-order version is also rejected.
564        #[arg(long, default_value_t = false)]
565        fake: bool,
566
567        /// Reason for faking these migrations. Required when `--fake`
568        /// is set. Persisted to the ledger's audit trail so future
569        /// inspections can understand why this version was recorded
570        /// without SQL execution. Has no effect on normal (non-fake)
571        /// apply.
572        #[arg(long)]
573        reason: Option<String>,
574
575        /// Explicit cluster node identity (0..=511). Wins over
576        /// `HEER_NODE_ID` env var. Mutually exclusive with
577        /// `--single-node-dev`. Required for identity-bearing operations
578        /// unless `--single-node-dev` is supplied or `HEER_NODE_ID` is set.
579        #[arg(long, conflicts_with = "single_node_dev")]
580        node_id: Option<u32>,
581
582        /// Single-node development mode — binds node 1 for the duration
583        /// of this operation. Mutually exclusive with `--node-id`.
584        /// Refused in production profile or `DJOGI_ENV=production`.
585        #[arg(long, default_value_t = false)]
586        single_node_dev: bool,
587    },
588    /// Roll back applied migrations in reverse ledger insertion order.
589    /// Use `--to <version>` to stop once `<version>` remains applied, and
590    /// `--dry-run` to preview the selected target set without executing SQL.
591    /// Lossy rollback stays fail-closed unless `--allow-data-loss` and
592    /// `--reason` are both supplied.
593    Rollback {
594        /// Stop once this version remains applied. Versions newer than
595        /// `--to` are selected for rollback; `--to` itself is kept.
596        #[arg(long)]
597        to: Option<String>,
598        /// Preview the selected rollback set without executing SQL or
599        /// mutating the ledger/snapshot.
600        #[arg(long, default_value_t = false)]
601        dry_run: bool,
602        /// Permit lossy rollback when the committed down SQL is marked
603        /// as data-losing. Requires `--reason`.
604        #[arg(long, default_value_t = false, requires = "reason")]
605        allow_data_loss: bool,
606        /// Audit-trail reason recorded when `--allow-data-loss` is used.
607        /// Only meaningful alongside `--allow-data-loss`; supplying it
608        /// alone is a parse error.
609        #[arg(long, requires = "allow_data_loss")]
610        reason: Option<String>,
611        /// App label for the migration bucket. Defaults to the global
612        /// bucket 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. Defaults to the current working
619        /// directory.
620        #[arg(long)]
621        workspace: Option<PathBuf>,
622        /// Explicit cluster node identity (0..=511). Required for
623        /// SQL-executing rollback unless `--single-node-dev` is supplied.
624        #[arg(long, conflicts_with = "single_node_dev")]
625        node_id: Option<u32>,
626        /// Single-node development mode — binds node 1 for rollback.
627        /// Refused in production profile or `DJOGI_ENV=production`.
628        #[arg(long, default_value_t = false)]
629        single_node_dev: bool,
630    },
631    /// Operator-confirmed repair flows for ledger drift, partial
632    /// applies, and missing snapshots. Every subcommand requires
633    /// explicit confirmation — invoking the CLI subcommand IS the
634    /// operator acknowledgment.
635    Repair {
636        /// The specific repair operation to perform.
637        #[command(subcommand)]
638        command: RepairSubcommand,
639    },
640    /// Project the live database schema into a baseline ledger row and
641    /// snapshot. Use for existing databases being adopted under Djogi's
642    /// migration ledger, where the schema already exists and compose +
643    /// apply cannot run on a populated database without a starting point.
644    /// Projects the live catalog into a single `baseline` ledger row
645    /// (no SQL runs against user tables) and writes the projected
646    /// snapshot so future migrations diff against the real DB state.
647    /// Invoking the subcommand IS the operator acknowledgment.
648    /// Requires PostgreSQL 18 or later — exits with code 2 if the
649    /// server is below the minimum.
650    /// Exit codes: 0 on success, 1 on runtime error (config / network /
651    /// SQL / projection failure), 2 on refusal (empty `--reason`, duplicate
652    /// version, unresolvable database URL, snapshot-persist failure after
653    /// ledger insert, session-pinning correctness failure, or below PG 18).
654    Baseline {
655        /// Version label for the baseline ledger row (e.g.
656        /// `V00000000000000__baseline`). Must be unique in the ledger.
657        version: String,
658        /// One-line description stored in the ledger row.
659        #[arg(long, default_value = "existing database schema baseline")]
660        description: String,
661        /// Required non-empty reason recorded in the baseline note
662        /// (audit trail entry).
663        #[arg(long)]
664        reason: String,
665        /// App label for the migration bucket. Defaults to the global
666        /// bucket (empty string) when not specified.
667        #[arg(long)]
668        app: Option<String>,
669        /// Database name. Defaults to `main` if not specified.
670        #[arg(long)]
671        database: Option<String>,
672        /// Workspace root override.
673        #[arg(long)]
674        workspace: Option<PathBuf>,
675        /// Explicit cluster node identity (0..=511). Required for
676        /// baseline unless `--single-node-dev` is supplied.
677        #[arg(long, conflicts_with = "single_node_dev")]
678        node_id: Option<u32>,
679        /// Single-node development mode — binds node 1 for baseline.
680        /// Refused in production profile or `DJOGI_ENV=production`.
681        #[arg(long, default_value_t = false)]
682        single_node_dev: bool,
683    },
684}
685
686/// `djogi migrations repair <subcommand>` — the four operator-confirmed
687/// repair flows.
688/// Each variant maps 1:1 onto a `djogi::migrate::repair::*` library
689/// function. Invoking the subcommand IS the operator acknowledgment;
690/// there is no separate `--confirm` flag. Every flow pins one Postgres
691/// session, takes the per-bucket advisory lock, and holds the workspace
692/// file lock for its duration.
693/// Exit codes (shared across all four): `0` success, `1`
694/// runtime/I/O error (retryable), `2` refusal or structural mismatch
695/// (operator must intervene).
696#[derive(Clone, Subcommand)]
697pub enum RepairSubcommand {
698    /// Update ledger checksum when migration file content changed
699    /// but the row was already applied.
700    ChecksumDrift {
701        /// Migration version (e.g. `V20260101000000__add_users`).
702        version: String,
703        /// App label for the migration bucket. Defaults to the global
704        /// bucket (empty string) when not specified.
705        #[arg(long)]
706        app: Option<String>,
707        /// Database name. Defaults to `main` if not specified.
708        #[arg(long)]
709        database: Option<String>,
710        /// New `checksum_up` value (SHA-256 hex). If omitted, computed
711        /// from the committed up SQL file.
712        #[arg(long)]
713        checksum_up: Option<String>,
714        /// New `checksum_down` value (SHA-256 hex). If omitted and
715        /// down file exists, computed from committed down SQL file.
716        /// Missing down file is a no-op; other read errors abort.
717        #[arg(long)]
718        checksum_down: Option<String>,
719        /// Workspace root override.
720        #[arg(long)]
721        workspace: Option<PathBuf>,
722    },
723
724    /// Resolve a partial-apply row by rewriting its status to one of
725    /// `rolled_back`, `faked`, or `applied`. Does NOT execute SQL.
726    PartialApply {
727        /// Migration version to repair.
728        version: String,
729        /// Resolution: `rolled-back`, `faked`, or `applied`.
730        #[arg(value_enum)]
731        resolution: PartialApplyResolutionCli,
732        /// Operator note persisted in the ledger row's
733        /// `partial_apply_note` column.
734        #[arg(long, default_value = "operator resolved partial apply via CLI")]
735        note: String,
736        /// App label (empty string for global bucket).
737        #[arg(long)]
738        app: Option<String>,
739        /// Database name. Defaults to `main` if not specified.
740        #[arg(long)]
741        database: Option<String>,
742        /// Workspace root override.
743        #[arg(long)]
744        workspace: Option<PathBuf>,
745    },
746
747    /// Resume an interrupted non-transactional apply by re-loading
748    /// the committed replay plan and executing remaining steps.
749    ResumePartial {
750        /// Migration version to resume.
751        version: String,
752        /// App label (empty string for global bucket).
753        #[arg(long)]
754        app: Option<String>,
755        /// Database name. Defaults to `main` if not specified.
756        #[arg(long)]
757        database: Option<String>,
758        /// Workspace root override.
759        #[arg(long)]
760        workspace: Option<PathBuf>,
761        /// Explicit cluster node identity (0..=511). Required for
762        /// SQL-executing resume unless `--single-node-dev` is supplied.
763        #[arg(long, conflicts_with = "single_node_dev")]
764        node_id: Option<u32>,
765        /// Single-node development mode — binds node 1 for resume.
766        /// Refused in production profile or `DJOGI_ENV=production`.
767        #[arg(long, default_value_t = false)]
768        single_node_dev: bool,
769    },
770
771    /// Rebuild the schema snapshot for a bucket by walking the
772    /// ledger and re-projecting from live database state.
773    SnapshotRebuild {
774        /// App label (empty string for global bucket).
775        #[arg(long)]
776        app: Option<String>,
777        /// Database name. Defaults to `main` if not specified.
778        #[arg(long)]
779        database: Option<String>,
780        /// Explicit snapshot path override. If omitted, derived from
781        /// `migrations/<database>/<app>/schema_snapshot.json`.
782        #[arg(long)]
783        snapshot_path: Option<PathBuf>,
784        /// Workspace root override.
785        #[arg(long)]
786        workspace: Option<PathBuf>,
787    },
788}
789
790/// CLI-side mirror of [`djogi::migrate::PartialApplyResolution`] for the
791/// `repair partial-apply` resolution argument.
792/// This enum exists only so `clap::ValueEnum` can parse
793/// `rolled-back | faked | applied` at the CLI boundary without the
794/// library enum carrying a clap-derive dependency. Conversion to the
795/// canonical [`djogi::migrate::PartialApplyResolution`] happens via the
796/// `From` impl in the `migrations` module.
797#[derive(clap::ValueEnum, Clone, Debug)]
798pub enum PartialApplyResolutionCli {
799    RolledBack,
800    Faked,
801    Applied,
802}
803
804// ── Entrypoints ───────────────────────────────────────────────────────────
805
806/// Run the CLI by parsing arguments from `std::env::args_os()`.
807/// This is the entry point used by the published standalone `djogi`
808/// binary. It reads the global link-time [`inventory`] registry via
809/// [`djogi::migrate::InventoryDescriptorProvider`].
810pub fn run_from_env() -> ExitCode {
811    let cli = match Cli::try_parse_from(std::env::args_os()) {
812        Ok(c) => c,
813        Err(e) => {
814            let _ = e.print();
815            return ExitCode::from(if e.use_stderr() { 2 } else { 0 });
816        }
817    };
818    dispatch_command(
819        &cli.command,
820        &djogi::migrate::InventoryDescriptorProvider::new(),
821    )
822}
823
824/// Run the CLI with an explicit argument iterable. Useful for testing and
825/// embedding.
826/// Accepts any `IntoIterator<Item = T>` where `T: Into<OsString> + Clone`,
827/// matching the bound of [`clap::Parser::try_parse_from`]. In practice,
828/// arrays of `&str` (e.g. `["djogi", "migrations", "compose"]`) and
829/// `Vec<String>` both satisfy this bound.
830/// Falls back to [`djogi::migrate::InventoryDescriptorProvider`] for
831/// descriptors.
832pub fn run_with_args<I, T>(args: I) -> ExitCode
833where
834    I: IntoIterator<Item = T>,
835    T: Into<std::ffi::OsString> + Clone,
836{
837    let cli = match Cli::try_parse_from(args) {
838        Ok(c) => c,
839        Err(e) => {
840            // Print the clap error / `--help` / `--version` text before
841            // returning, matching `run_from_env`. Without this, parse
842            // errors and `--help` would be silent.
843            let _ = e.print();
844            return ExitCode::from(if e.use_stderr() { 2 } else { 0 });
845        }
846    };
847    dispatch_command(
848        &cli.command,
849        &djogi::migrate::InventoryDescriptorProvider::new(),
850    )
851}
852
853/// Run the CLI with an explicit argument iterable and a [`DescriptorProvider`].
854/// Accepts any `IntoIterator<Item = T>` where `T: Into<OsString> + Clone`,
855/// matching the bound of [`clap::Parser::try_parse_from`].
856/// Adopter-linked binaries pass their own provider so descriptor-dependent
857/// commands (`compose`, `verify`, `schema`, `docs`) see the adopter's
858/// models instead of an empty inventory.
859pub fn run_with_provider<I, T>(
860    args: I,
861    provider: &dyn djogi::migrate::DescriptorProvider,
862) -> ExitCode
863where
864    I: IntoIterator<Item = T>,
865    T: Into<std::ffi::OsString> + Clone,
866{
867    let cli = match Cli::try_parse_from(args) {
868        Ok(c) => c,
869        Err(e) => {
870            // Print the clap error / `--help` / `--version` text before
871            // returning, matching `run_from_env`.
872            let _ = e.print();
873            return ExitCode::from(if e.use_stderr() { 2 } else { 0 });
874        }
875    };
876    dispatch_command(&cli.command, provider)
877}
878
879// ── Dispatch ──────────────────────────────────────────────────────────────
880
881fn dispatch_command(
882    command: &TopCommand,
883    provider: &dyn djogi::migrate::DescriptorProvider,
884) -> ExitCode {
885    match command {
886        TopCommand::Shell => {
887            eprintln!("djogi shell: not yet implemented");
888            ExitCode::from(0)
889        }
890        TopCommand::Db { command } => match command {
891            DbCommand::Reset {
892                yes,
893                allow_checksum_drift_reset,
894                maintenance_database,
895                workspace,
896                node_id,
897                single_node_dev,
898            } => db::reset_cmd(
899                *yes,
900                *allow_checksum_drift_reset,
901                maintenance_database.clone(),
902                workspace.clone(),
903                *node_id,
904                *single_node_dev,
905            ),
906            DbCommand::Seed {
907                database,
908                allow_non_localhost,
909                workspace,
910            } => db::seed_cmd(database.clone(), *allow_non_localhost, workspace.clone()),
911            DbCommand::CleanupTestDbs {
912                dry_run,
913                yes,
914                maintenance_database,
915                allow_non_localhost,
916                workspace,
917            } => db::cleanup_test_dbs_cmd(
918                *dry_run,
919                *yes,
920                maintenance_database.clone(),
921                *allow_non_localhost,
922                workspace.clone(),
923            ),
924        },
925        TopCommand::Docs { output, workspace } => {
926            if provider.models().is_empty() {
927                print_zero_descriptor_diagnostic("docs");
928                return ExitCode::from(2);
929            }
930            db::docs_cmd(provider, output.clone(), workspace.clone())
931        }
932        TopCommand::Live { command } => live::dispatch(command.clone()),
933        TopCommand::Verify { workspace } => {
934            let runtime = match tokio::runtime::Builder::new_current_thread()
935                .enable_all()
936                .build()
937            {
938                Ok(r) => r,
939                Err(e) => {
940                    eprintln!("djogi verify: tokio runtime: {e}");
941                    return ExitCode::from(1);
942                }
943            };
944            match runtime.block_on(verify::run(workspace.clone())) {
945                Ok(code) => code,
946                Err(e) => {
947                    eprintln!("djogi verify: {e}");
948                    ExitCode::from(1)
949                }
950            }
951        }
952        TopCommand::Schema { format, output } => {
953            let models: Vec<&'static djogi::descriptor::ModelDescriptor> = provider.models();
954            if models.is_empty() {
955                print_zero_descriptor_diagnostic("schema");
956                return ExitCode::from(2);
957            }
958            match schema::run(format.into_schema(), &models, output.clone()) {
959                Ok(()) => ExitCode::SUCCESS,
960                Err(e) => {
961                    eprintln!("djogi schema: {e}");
962                    ExitCode::from(1)
963                }
964            }
965        }
966        TopCommand::Analyze {
967            format,
968            threshold_vacuum,
969            threshold_partition_rows,
970            workspace,
971        } => {
972            let runtime = match tokio::runtime::Builder::new_current_thread()
973                .enable_all()
974                .build()
975            {
976                Ok(r) => r,
977                Err(e) => {
978                    eprintln!("djogi analyze: tokio runtime: {e}");
979                    return ExitCode::from(1);
980                }
981            };
982            match runtime.block_on(analyze::run(
983                workspace.clone(),
984                format.into_analyze(),
985                *threshold_vacuum,
986                *threshold_partition_rows,
987            )) {
988                Ok(()) => ExitCode::SUCCESS,
989                Err(e) => {
990                    eprintln!("djogi analyze: {e}");
991                    ExitCode::from(1)
992                }
993            }
994        }
995        TopCommand::Migrations { command } => match command {
996            MigrationsCommand::Compose {
997                name,
998                allow_destructive,
999                force_overwrite,
1000                workspace,
1001            } => {
1002                if provider.models().is_empty() {
1003                    print_zero_descriptor_diagnostic("migrations compose");
1004                    return ExitCode::from(2);
1005                }
1006                migrations::compose_cmd(
1007                    provider,
1008                    name.as_str(),
1009                    *allow_destructive,
1010                    *force_overwrite,
1011                    workspace.clone(),
1012                )
1013            }
1014            MigrationsCommand::Status { workspace } => migrations::status_cmd(workspace.clone()),
1015            MigrationsCommand::Verify { workspace, strict } => {
1016                migrations::verify_cmd(provider, workspace.clone(), *strict)
1017            }
1018            MigrationsCommand::Attune {
1019                target,
1020                apply,
1021                record,
1022                record_ledger,
1023                record_reason,
1024                squash,
1025                from,
1026                publish,
1027                app,
1028                workspace,
1029            } => migrations::attune_cmd(
1030                target.as_deref(),
1031                *apply,
1032                *record,
1033                *record_ledger,
1034                record_reason.as_str(),
1035                *squash,
1036                from.as_deref(),
1037                *publish,
1038                app.as_deref(),
1039                workspace.clone(),
1040            ),
1041            MigrationsCommand::Apply {
1042                workspace,
1043                fake,
1044                reason,
1045                node_id,
1046                single_node_dev,
1047            } => migrations::apply_cmd(
1048                workspace.clone(),
1049                *fake,
1050                reason.clone(),
1051                *node_id,
1052                *single_node_dev,
1053            ),
1054            MigrationsCommand::Rollback {
1055                to,
1056                dry_run,
1057                allow_data_loss,
1058                reason,
1059                app,
1060                database,
1061                workspace,
1062                node_id,
1063                single_node_dev,
1064            } => migrations::rollback_cmd(
1065                to.clone(),
1066                *dry_run,
1067                *allow_data_loss,
1068                reason.clone(),
1069                app.as_deref(),
1070                database.as_deref(),
1071                workspace.clone(),
1072                *node_id,
1073                *single_node_dev,
1074            ),
1075            MigrationsCommand::Repair { command } => migrations::repair_cmd(command.clone()),
1076            MigrationsCommand::Baseline {
1077                version,
1078                description,
1079                reason,
1080                app,
1081                database,
1082                workspace,
1083                node_id,
1084                single_node_dev,
1085            } => migrations::baseline_cmd(
1086                version,
1087                description,
1088                reason,
1089                app.as_deref(),
1090                database.as_deref(),
1091                workspace.clone(),
1092                *node_id,
1093                *single_node_dev,
1094            ),
1095        },
1096        TopCommand::Migrate { command } => match command {
1097            MigrateCommand::Apply {
1098                workspace,
1099                fake,
1100                reason,
1101                node_id,
1102                single_node_dev,
1103            } => migrations::apply_cmd(
1104                workspace.clone(),
1105                *fake,
1106                reason.clone(),
1107                *node_id,
1108                *single_node_dev,
1109            ),
1110        },
1111    }
1112}
1113
1114/// Print the §5.6 dual-cause diagnostic when a descriptor-dependent
1115/// command (`compose` / `verify` / `schema` / `docs`) resolves zero model
1116/// descriptors, and exits the command with code `2` (refusal — the
1117/// command refuses because it cannot see the schema it needs).
1118/// The message is dual-cause because zero descriptors has two distinct
1119/// causes the operator must be able to tell apart:
1120/// 1. they ran the *standalone published* `djogi`, which links no
1121///    application models (build an adopter-linked `djogi` and run from it;
1122///    the standalone binary can still `migrations apply`); or
1123/// 2. this *is* their adopter-linked `djogi` but the linker dropped an
1124///    unreferenced model crate (ensure every `#[derive(Model)]` crate is
1125///    referenced via `link_models` / `djogi_main!`).
1126///    The first line is kept verbatim in sync with the troubleshooting
1127///    anchor in `docs/guide/adopter-cli.md` ("no djogi models are registered
1128///    in this binary") so an operator who searches the message lands on the
1129///    guide section that explains it.
1130///    `command` is the failing command name (e.g. `"migrations compose"`),
1131///    echoed so the operator knows which invocation refused. The single
1132///    emitter feeds `compose`, `verify`, `schema`, and `docs`, so one message
1133///    covers all four.
1134pub(crate) fn print_zero_descriptor_diagnostic(command: &str) {
1135    eprintln!("error: no djogi models are registered in this binary (djogi {command}).");
1136    eprintln!();
1137    eprintln!("Descriptor-dependent commands (compose, verify, schema, docs) require a");
1138    eprintln!("djogi binary linked with your model crates.");
1139    eprintln!();
1140    eprintln!("  • If you ran the standalone published `djogi`: that binary links no");
1141    eprintln!("    application models. Build an adopter-linked `djogi` (see the adopter");
1142    eprintln!("    CLI guide: docs/guide/adopter-cli.md) and run the command from it.");
1143    eprintln!("    The standalone binary can still run `djogi migrations apply` against");
1144    eprintln!("    already-composed pending artifacts.");
1145    eprintln!();
1146    eprintln!("  • If this IS your adopter-linked `djogi`: ensure your bin references");
1147    eprintln!("    every crate that defines `#[derive(Model)]` (link_models / djogi_main!),");
1148    eprintln!("    or the linker may have dropped an unreferenced model crate.");
1149}
1150
1151#[cfg(test)]
1152/// Single process-wide lock for tests that mutate process env vars.
1153/// `std::sync::Mutex` is non-reentrant: do not hold two env guards on
1154/// the same thread or the second lock attempt will deadlock.
1155pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
1156    static ENV_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
1157    ENV_LOCK
1158        .get_or_init(|| std::sync::Mutex::new(()))
1159        .lock()
1160        .unwrap()
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165    //! CLI-level argument-parsing tests. These exercise the `value_parser`
1166    //! attached to `--threshold-vacuum` directly; the goal is to pin the
1167    //! contract that nonsense input fails at parse time rather than
1168    //! silently producing a recommendation engine that "never fires."
1169
1170    use clap::Parser as _;
1171
1172    use std::path::PathBuf;
1173
1174    use super::{
1175        Cli, DbCommand, MigrateCommand, MigrationsCommand, PartialApplyResolutionCli,
1176        RepairSubcommand, TopCommand, parse_threshold_vacuum,
1177    };
1178
1179    #[test]
1180    fn parse_threshold_vacuum_accepts_valid_values() {
1181        assert_eq!(parse_threshold_vacuum("0.0").unwrap(), 0.0);
1182        assert_eq!(parse_threshold_vacuum("0.2").unwrap(), 0.2);
1183        assert_eq!(parse_threshold_vacuum("1.0").unwrap(), 1.0);
1184        // Boundary check: strictly inside the closed interval.
1185        assert_eq!(parse_threshold_vacuum("0.5").unwrap(), 0.5);
1186    }
1187
1188    #[test]
1189    fn parse_threshold_vacuum_rejects_nan_inf_and_out_of_range() {
1190        // NaN — the entire reason this validator exists. `ratio > NaN`
1191        // is always false, so silent acceptance would mean VacuumNeeded
1192        // never fires, ever.
1193        let err = parse_threshold_vacuum("NaN").unwrap_err();
1194        assert!(err.contains("finite"), "err: {err}");
1195
1196        // Positive infinity — same silent-failure mode.
1197        let err = parse_threshold_vacuum("inf").unwrap_err();
1198        assert!(err.contains("finite"), "err: {err}");
1199
1200        // Negative infinity.
1201        let err = parse_threshold_vacuum("-inf").unwrap_err();
1202        assert!(err.contains("finite"), "err: {err}");
1203
1204        // Negative finite — outside `[0.0, 1.0]`.
1205        let err = parse_threshold_vacuum("-0.1").unwrap_err();
1206        assert!(err.contains("[0.0, 1.0]"), "err: {err}");
1207
1208        // Above 1.0 — outside `[0.0, 1.0]`.
1209        let err = parse_threshold_vacuum("1.5").unwrap_err();
1210        assert!(err.contains("[0.0, 1.0]"), "err: {err}");
1211
1212        // Garbage — propagates the underlying ParseFloatError message.
1213        assert!(parse_threshold_vacuum("not-a-number").is_err());
1214    }
1215
1216    #[test]
1217    fn db_reset_parses_allow_checksum_drift_reset_flag() {
1218        let cli = Cli::try_parse_from([
1219            "djogi",
1220            "db",
1221            "reset",
1222            "--yes",
1223            "--allow-checksum-drift-reset",
1224        ])
1225        .expect("flag should parse");
1226
1227        match cli.command {
1228            TopCommand::Db {
1229                command:
1230                    DbCommand::Reset {
1231                        yes,
1232                        allow_checksum_drift_reset,
1233                        ..
1234                    },
1235            } => {
1236                assert!(yes, "--yes should parse through");
1237                assert!(
1238                    allow_checksum_drift_reset,
1239                    "checksum-drift override flag should parse through"
1240                );
1241            }
1242            _ => panic!("expected db reset command"),
1243        }
1244    }
1245
1246    #[test]
1247    fn migrate_apply_alias_parses() {
1248        let cli = Cli::try_parse_from(["djogi", "migrate", "apply"])
1249            .expect("migrate apply should parse as alias");
1250
1251        match cli.command {
1252            TopCommand::Migrate {
1253                command: MigrateCommand::Apply { .. },
1254            } => {}
1255            _ => panic!("expected migrate apply command"),
1256        }
1257    }
1258
1259    #[test]
1260    fn canonical_migrations_apply_parses() {
1261        let cli = Cli::try_parse_from(["djogi", "migrations", "apply"])
1262            .expect("canonical migrations apply should parse");
1263
1264        match cli.command {
1265            TopCommand::Migrations {
1266                command: MigrationsCommand::Apply { .. },
1267            } => {}
1268            _ => panic!("expected migrations apply command"),
1269        }
1270    }
1271
1272    #[test]
1273    fn canonical_migrations_status_still_parses() {
1274        let cli = Cli::try_parse_from(["djogi", "migrations", "status"])
1275            .expect("canonical migrations status should parse");
1276
1277        match cli.command {
1278            TopCommand::Migrations {
1279                command: MigrationsCommand::Status { .. },
1280            } => {}
1281            _ => panic!("expected migrations status command"),
1282        }
1283    }
1284
1285    #[test]
1286    fn migrations_verify_parses_with_defaults() {
1287        let cli = Cli::try_parse_from(["djogi", "migrations", "verify"])
1288            .expect("migrations verify should parse with no flags");
1289
1290        match cli.command {
1291            TopCommand::Migrations {
1292                command: MigrationsCommand::Verify { workspace, strict },
1293            } => {
1294                assert!(workspace.is_none());
1295                assert!(!strict);
1296            }
1297            _ => panic!("expected migrations verify command"),
1298        }
1299    }
1300
1301    #[test]
1302    fn migrations_verify_parses_with_strict() {
1303        let cli = Cli::try_parse_from(["djogi", "migrations", "verify", "--strict"])
1304            .expect("migrations verify --strict should parse");
1305
1306        match cli.command {
1307            TopCommand::Migrations {
1308                command: MigrationsCommand::Verify { strict, .. },
1309            } => {
1310                assert!(strict);
1311            }
1312            _ => panic!("expected migrations verify command"),
1313        }
1314    }
1315
1316    #[test]
1317    fn migrations_verify_parses_with_workspace() {
1318        let cli = Cli::try_parse_from([
1319            "djogi",
1320            "migrations",
1321            "verify",
1322            "--workspace",
1323            "/custom/path",
1324        ])
1325        .expect("migrations verify --workspace should parse");
1326
1327        match cli.command {
1328            TopCommand::Migrations {
1329                command: MigrationsCommand::Verify { workspace, .. },
1330            } => {
1331                assert_eq!(workspace, Some(PathBuf::from("/custom/path")));
1332            }
1333            _ => panic!("expected migrations verify command"),
1334        }
1335    }
1336
1337    // ── repair subcommand argument parsing ─────────────────────────────────
1338
1339    #[test]
1340    fn parse_repair_checksum_drift_accepts_required_args() {
1341        let cli = Cli::parse_from([
1342            "djogi",
1343            "migrations",
1344            "repair",
1345            "checksum-drift",
1346            "V20260101000000__test",
1347            "--checksum-up",
1348            "V1:aaaa",
1349        ]);
1350        assert!(matches!(cli.command, TopCommand::Migrations { .. }));
1351    }
1352
1353    #[test]
1354    fn parse_repair_checksum_drift_rejects_missing_version() {
1355        let result = Cli::try_parse_from(["djogi", "migrations", "repair", "checksum-drift"]);
1356        assert!(result.is_err(), "must require version argument");
1357    }
1358
1359    #[test]
1360    fn parse_repair_partial_apply_accepts_resolution_values() {
1361        for resolution in ["rolled-back", "faked", "applied"] {
1362            let cli = Cli::parse_from([
1363                "djogi",
1364                "migrations",
1365                "repair",
1366                "partial-apply",
1367                "V20260101000000__test",
1368                resolution,
1369            ]);
1370            assert!(
1371                matches!(cli.command, TopCommand::Migrations { .. }),
1372                "resolution={resolution}"
1373            );
1374        }
1375    }
1376
1377    #[test]
1378    fn parse_repair_partial_apply_rejects_invalid_resolution() {
1379        let result = Cli::try_parse_from([
1380            "djogi",
1381            "migrations",
1382            "repair",
1383            "partial-apply",
1384            "V20260101000000__test",
1385            "invalid-resolution",
1386        ]);
1387        assert!(result.is_err(), "must reject unknown resolution");
1388    }
1389
1390    #[test]
1391    fn parse_repair_resume_partial_accepts_version() {
1392        let cli = Cli::parse_from([
1393            "djogi",
1394            "migrations",
1395            "repair",
1396            "resume-partial",
1397            "V20260101000000__test",
1398        ]);
1399        assert!(matches!(cli.command, TopCommand::Migrations { .. }));
1400    }
1401
1402    #[test]
1403    fn parse_repair_snapshot_rebuild_accepts_flags() {
1404        let cli = Cli::parse_from([
1405            "djogi",
1406            "migrations",
1407            "repair",
1408            "snapshot-rebuild",
1409            "--app",
1410            "myapp",
1411        ]);
1412        assert!(matches!(cli.command, TopCommand::Migrations { .. }));
1413    }
1414
1415    // Field-binding destructuring tests — one per subcommand that carries
1416    // arguments. The outer-shape `matches!(..)` tests above prove the
1417    // variant is reached; these prove the named clap fields actually bind
1418    // to the supplied values (catching a `#[arg(long)]` typo or a
1419    // positional/flag mix-up that an outer-shape assertion would miss).
1420
1421    #[test]
1422    fn parse_repair_checksum_drift_binds_version_and_checksum_up() {
1423        let cli = Cli::parse_from([
1424            "djogi",
1425            "migrations",
1426            "repair",
1427            "checksum-drift",
1428            "V20260101000000__add_users",
1429            "--checksum-up",
1430            "V1:aaaa",
1431        ]);
1432        if let TopCommand::Migrations {
1433            command: MigrationsCommand::Repair { command },
1434        } = cli.command
1435        {
1436            if let RepairSubcommand::ChecksumDrift {
1437                version,
1438                checksum_up,
1439                ..
1440            } = command
1441            {
1442                assert_eq!(version, "V20260101000000__add_users");
1443                assert_eq!(checksum_up.as_deref(), Some("V1:aaaa"));
1444            } else {
1445                panic!("wrong variant");
1446            }
1447        } else {
1448            panic!("wrong command");
1449        }
1450    }
1451
1452    #[test]
1453    fn parse_repair_partial_apply_binds_resolution_and_note() {
1454        let cli = Cli::parse_from([
1455            "djogi",
1456            "migrations",
1457            "repair",
1458            "partial-apply",
1459            "V20260101000000__add_users",
1460            "rolled-back",
1461            "--note",
1462            "reverted by hot-fix",
1463        ]);
1464        if let TopCommand::Migrations {
1465            command: MigrationsCommand::Repair { command },
1466        } = cli.command
1467        {
1468            if let RepairSubcommand::PartialApply {
1469                version,
1470                resolution,
1471                note,
1472                ..
1473            } = command
1474            {
1475                assert_eq!(version, "V20260101000000__add_users");
1476                assert!(matches!(resolution, PartialApplyResolutionCli::RolledBack));
1477                assert_eq!(note, "reverted by hot-fix");
1478            } else {
1479                panic!("wrong variant");
1480            }
1481        } else {
1482            panic!("wrong command");
1483        }
1484    }
1485
1486    #[test]
1487    fn parse_repair_snapshot_rebuild_binds_app_and_database() {
1488        let cli = Cli::parse_from([
1489            "djogi",
1490            "migrations",
1491            "repair",
1492            "snapshot-rebuild",
1493            "--app",
1494            "billing",
1495            "--database",
1496            "analytics",
1497        ]);
1498        if let TopCommand::Migrations {
1499            command: MigrationsCommand::Repair { command },
1500        } = cli.command
1501        {
1502            if let RepairSubcommand::SnapshotRebuild { app, database, .. } = command {
1503                assert_eq!(app.as_deref(), Some("billing"));
1504                assert_eq!(database.as_deref(), Some("analytics"));
1505            } else {
1506                panic!("wrong variant");
1507            }
1508        } else {
1509            panic!("wrong command");
1510        }
1511    }
1512
1513    // ── baseline subcommand argument parsing ───────────────────────────────
1514
1515    /// Extract the `MigrationsCommand::Baseline` variant from a parsed
1516    /// `Cli`, panicking on any other shape. Used by the baseline
1517    /// field-binding tests below so each test reads as a flat sequence
1518    /// of field assertions rather than nested `if let`s.
1519    fn baseline_command(cli: Cli) -> MigrationsCommand {
1520        match cli.command {
1521            TopCommand::Migrations {
1522                command: command @ MigrationsCommand::Baseline { .. },
1523            } => command,
1524            _ => panic!("expected migrations baseline command"),
1525        }
1526    }
1527
1528    #[test]
1529    fn parse_baseline_accepts_required_args() {
1530        let cli = Cli::try_parse_from([
1531            "djogi",
1532            "migrations",
1533            "baseline",
1534            "V00000000000000__baseline",
1535            "--reason",
1536            "schema pre-exists from prior tooling",
1537        ])
1538        .unwrap();
1539        let MigrationsCommand::Baseline {
1540            version,
1541            reason,
1542            description,
1543            app,
1544            database,
1545            ..
1546        } = baseline_command(cli)
1547        else {
1548            panic!("expected Baseline");
1549        };
1550        assert_eq!(version, "V00000000000000__baseline");
1551        assert_eq!(reason, "schema pre-exists from prior tooling");
1552        assert_eq!(description, "existing database schema baseline");
1553        assert!(app.is_none());
1554        assert!(database.is_none());
1555    }
1556
1557    #[test]
1558    fn parse_baseline_rejects_missing_version() {
1559        let result = Cli::try_parse_from(["djogi", "migrations", "baseline", "--reason", "test"]);
1560        assert!(
1561            result.is_err(),
1562            "baseline without version positional should fail"
1563        );
1564    }
1565
1566    #[test]
1567    fn parse_baseline_rejects_missing_reason() {
1568        let result = Cli::try_parse_from([
1569            "djogi",
1570            "migrations",
1571            "baseline",
1572            "V00000000000000__baseline",
1573        ]);
1574        assert!(result.is_err(), "baseline without --reason should fail");
1575    }
1576
1577    #[test]
1578    fn parse_baseline_accepts_optional_flags() {
1579        let cli = Cli::try_parse_from([
1580            "djogi",
1581            "migrations",
1582            "baseline",
1583            "V00000000000000__baseline",
1584            "--reason",
1585            "existing schema",
1586            "--description",
1587            "custom description",
1588            "--app",
1589            "billing",
1590            "--database",
1591            "crud_log",
1592        ])
1593        .unwrap();
1594        let MigrationsCommand::Baseline {
1595            version,
1596            reason,
1597            description,
1598            app,
1599            database,
1600            ..
1601        } = baseline_command(cli)
1602        else {
1603            panic!("expected Baseline");
1604        };
1605        assert_eq!(version, "V00000000000000__baseline");
1606        assert_eq!(reason, "existing schema");
1607        assert_eq!(description, "custom description");
1608        assert_eq!(app.as_deref(), Some("billing"));
1609        assert_eq!(database.as_deref(), Some("crud_log"));
1610    }
1611
1612    // ── rollback subcommand argument parsing ───────────────────────────────
1613
1614    /// Extract the `MigrationsCommand::Rollback` variant from a parsed
1615    /// `Cli`, panicking on any other shape.
1616    fn rollback_command(cli: Cli) -> MigrationsCommand {
1617        match cli.command {
1618            TopCommand::Migrations {
1619                command: command @ MigrationsCommand::Rollback { .. },
1620            } => command,
1621            _ => panic!("expected migrations rollback command"),
1622        }
1623    }
1624
1625    #[test]
1626    fn parse_rollback_accepts_required_reason_for_lossy_opt_in() {
1627        let cli = Cli::try_parse_from([
1628            "djogi",
1629            "migrations",
1630            "rollback",
1631            "--allow-data-loss",
1632            "--reason",
1633            "operator confirmed rollback",
1634        ])
1635        .unwrap();
1636        let MigrationsCommand::Rollback {
1637            to,
1638            dry_run,
1639            allow_data_loss,
1640            reason,
1641            app,
1642            database,
1643            node_id,
1644            single_node_dev,
1645            ..
1646        } = rollback_command(cli)
1647        else {
1648            panic!("expected Rollback");
1649        };
1650        assert!(to.is_none());
1651        assert!(!dry_run);
1652        assert!(allow_data_loss);
1653        assert_eq!(reason.as_deref(), Some("operator confirmed rollback"));
1654        assert!(app.is_none());
1655        assert!(database.is_none());
1656        assert!(node_id.is_none());
1657        assert!(!single_node_dev);
1658    }
1659
1660    #[test]
1661    fn parse_rollback_accepts_to_dry_run_and_bucket_flags() {
1662        let cli = Cli::try_parse_from([
1663            "djogi",
1664            "migrations",
1665            "rollback",
1666            "--to",
1667            "V20260101000000__baseline",
1668            "--dry-run",
1669            "--app",
1670            "billing",
1671            "--database",
1672            "analytics",
1673        ])
1674        .unwrap();
1675        let MigrationsCommand::Rollback {
1676            to,
1677            dry_run,
1678            allow_data_loss,
1679            reason,
1680            app,
1681            database,
1682            node_id,
1683            single_node_dev,
1684            ..
1685        } = rollback_command(cli)
1686        else {
1687            panic!("expected Rollback");
1688        };
1689        assert_eq!(to.as_deref(), Some("V20260101000000__baseline"));
1690        assert!(dry_run);
1691        assert!(!allow_data_loss);
1692        assert!(reason.is_none());
1693        assert_eq!(app.as_deref(), Some("billing"));
1694        assert_eq!(database.as_deref(), Some("analytics"));
1695        assert!(node_id.is_none());
1696        assert!(!single_node_dev);
1697    }
1698
1699    #[test]
1700    fn parse_rollback_rejects_lossy_opt_in_without_reason() {
1701        let result = Cli::try_parse_from(["djogi", "migrations", "rollback", "--allow-data-loss"]);
1702        assert!(
1703            result.is_err(),
1704            "rollback --allow-data-loss without --reason should fail"
1705        );
1706    }
1707
1708    #[test]
1709    fn parse_rollback_rejects_reason_without_allow_data_loss() {
1710        // `--reason` is only meaningful with `--allow-data-loss`; clap should
1711        // reject it on its own so an operator cannot silently supply a lossy
1712        // audit reason that has no effect.
1713        let result = Cli::try_parse_from([
1714            "djogi",
1715            "migrations",
1716            "rollback",
1717            "--reason",
1718            "operator confirmed rollback",
1719        ]);
1720        assert!(
1721            result.is_err(),
1722            "rollback --reason without --allow-data-loss should fail"
1723        );
1724    }
1725
1726    #[test]
1727    fn parse_rollback_accepts_allow_data_loss_and_reason_together() {
1728        let result = Cli::try_parse_from([
1729            "djogi",
1730            "migrations",
1731            "rollback",
1732            "--allow-data-loss",
1733            "--reason",
1734            "operator confirmed rollback",
1735        ]);
1736        assert!(
1737            result.is_ok(),
1738            "rollback --allow-data-loss with --reason should parse"
1739        );
1740    }
1741}