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