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}