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