Skip to main content

djogi_cli/
db.rs

1//! `djogi db` and `djogi docs` subcommand glue
2//! Three leaves:
3//! - `db reset` — drops, recreates, and replays committed migrations
4//!   for the application database. Triple-gated (localhost +
5//!   non-production profile + explicit `--yes`) per the brief.
6//! - `db seed` — runs operator-authored SQL fixtures from
7//!   `seeds/<database>/`. Localhost-or-`--allow-non-localhost`.
8//! - `docs` — renders per-model markdown reference pages from the
9//!   descriptor inventory.
10//!   All three flow through public APIs in `djogi::migrate` (or
11//!   `::config`) so integration tests can exercise the underlying logic
12//!   without spawning subprocesses.
13//! # Exit codes
14//! Every subcommand in this module obeys a uniform three-value matrix
15//! so shell integrations can distinguish "operation refused" from
16//! "operation failed":
17//! | Code | Meaning |
18//! |------|---------|
19//! | `0` | Success — the command completed and any post-state was applied. |
20//! | `1` | Error — config load failure, network, SQL, or any other underlying runtime failure. |
21//! | `2` | Refusal — either a policy gate (localhost, production profile, missing `--yes`, …) blocked execution before any side effect, OR clap-style argument validation rejected the invocation (missing flag, mutually exclusive flags). |
22//! Exit code `2` deliberately bundles policy refusals and
23//! argument-validation errors. Clap's default behaviour is to return
24//! `2` for unknown / malformed flags; manual `2` returns in
25//! `migrations attune` (missing `--from`, conflicting flags) and the
26//! `db reset` / `db seed` gates intentionally share that code so a
27//! CI script can treat any `2` as "operator must intervene; nothing
28//! happened" without distinguishing the two cases. `1` is reserved
29//! for "we tried; something broke" so a CI can retry. The matrix is
30//! also documented in `ReadMe.MD` and `docs/spec/configuration.md`
31//! so the operator-facing surface stays in sync.
32
33use std::io::{BufRead, Write};
34use std::path::{Path, PathBuf};
35use std::process::ExitCode;
36
37use djogi::config::DjogiConfig;
38use djogi::migrate::{
39    DescriptorProvider, ResetError, ResetReport, ResetRequest, SeedError, SeedOutcome, SeedReport,
40    generate_docs_with_provider, reset_app_database, run_seeds,
41};
42
43/// Resolve the workspace root from the `--workspace` flag. Default:
44/// the current working directory. Mirrors the helper in
45/// [`crate::migrations`].
46fn resolve_workspace(workspace: Option<PathBuf>) -> PathBuf {
47    workspace.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
48}
49
50/// Build a Tokio current-thread runtime for the synchronous CLI
51/// surface. Reused by `db reset` and `db seed` — both need to drive
52/// async library calls from a sync `fn main()` shape.
53fn build_runtime(label: &str) -> Result<tokio::runtime::Runtime, ExitCode> {
54    tokio::runtime::Builder::new_current_thread()
55        .enable_all()
56        .build()
57        .map_err(|e| {
58            eprintln!("djogi {label}: tokio runtime: {e}");
59            ExitCode::from(1)
60        })
61}
62
63// ── db reset ──────────────────────────────────────────────────────────────
64
65/// `djogi db reset` entry point.
66/// `yes`: when `true`, the function does NOT prompt the operator
67/// the request flows straight into [`reset_app_database`]. When
68/// `false`, the function prints a y/N prompt to stderr and reads
69/// stdin; only an explicit `y` / `yes` answer (case-insensitive)
70/// proceeds. Any other input refuses with the standard
71/// `ResetRefusal::NotConfirmed` exit code.
72/// `maintenance_database` defaults to `"postgres"` — the conventional
73/// administrative DB present on every cluster — when the operator
74/// supplies nothing more specific.
75pub fn reset_cmd(
76    yes: bool,
77    allow_checksum_drift_reset: bool,
78    maintenance_database: String,
79    workspace: Option<PathBuf>,
80    node_id: Option<u32>,
81    single_node_dev: bool,
82) -> ExitCode {
83    let workspace = resolve_workspace(workspace);
84    let config = match DjogiConfig::load_from_workspace(&workspace) {
85        Ok(c) => c,
86        Err(e) => {
87            eprintln!("djogi db reset: config load: {e}");
88            return ExitCode::from(1);
89        }
90    };
91
92    // Resolve node identity before the interactive prompt.
93    // Reset requires explicit single-node-dev mode — selected-node is refused
94    // because destructive drop/recreate on an identity-bearing node could
95    // permanently lose registered state.
96    let runner_identity = match crate::identity::resolve_identity(
97        node_id,
98        single_node_dev,
99        &config.profile,
100        "db reset",
101    ) {
102        Ok(resolved) => {
103            // Selected-node reset is refused — only --single-node-dev is permitted.
104            match resolved {
105                crate::identity::CliResolvedIdentity::SingleNodeDev => {
106                    Some(djogi::migrate::RunnerIdentity::SingleNodeDev)
107                }
108                crate::identity::CliResolvedIdentity::Selected(id) => {
109                    eprintln!(
110                        "djogi db reset: refused — selected node {id} is not \
111                         permitted for destructive reset; use --single-node-dev"
112                    );
113                    return ExitCode::from(2);
114                }
115            }
116        }
117        Err(e) => {
118            eprintln!("djogi db reset: refused — {e}");
119            return ExitCode::from(2);
120        }
121    };
122
123    // If the operator omitted `--yes`, run an interactive prompt
124    // BEFORE we touch the runtime — minimises blast radius in the
125    // refusal path.
126    let confirmed = if yes {
127        true
128    } else {
129        match interactive_confirm(&config.database.url) {
130            Ok(c) => c,
131            Err(_) => {
132                // I/O error reading stdin — refuse rather than guess.
133                eprintln!(
134                    "djogi db reset: failed to read confirmation; \
135                     refusing without an explicit `--yes`"
136                );
137                return ExitCode::from(1);
138            }
139        }
140    };
141
142    let runtime = match build_runtime("db reset") {
143        Ok(r) => r,
144        Err(code) => return code,
145    };
146
147    let exit = runtime.block_on(async {
148        run_reset(
149            &workspace,
150            &config,
151            &maintenance_database,
152            confirmed,
153            allow_checksum_drift_reset,
154            runner_identity,
155        )
156        .await
157    });
158    ExitCode::from(exit as u8)
159}
160
161/// Async body of [`reset_cmd`]. Returns the desired exit code.
162/// **Audit pool wire-up (issue #118).** The `db reset` replay path
163/// passes its `RunnerCtx` through to `apply_plan`, which writes a
164/// `djogi_ddl_audit` row per executed segment when given a
165/// `Some(audit_pool)`. The CLI resolves the audit DB URL via
166/// [`djogi::migrate::resolve_audit_url`] (`CRUD_LOG_URL` / compatibility
167/// env, `[database].crud_log_url`, then derive `crud_log` from
168/// `database.url`) and constructs the pool via
169/// [`djogi::migrate::build_audit_pool`].
170/// Audit pool construction is **best-effort**: a missing
171/// `Djogi.toml::database.url` path component, an unreachable audit DB,
172/// or a self-audit refusal degrades to `audit_pool = None` with a
173/// `tracing::warn!`. We do not block the destructive `db reset` over a
174/// sibling-DB outage; the runner's own audit-write loop already
175/// degrades silently when the pool is absent (see
176/// `record_ddl_audit_for_plan`'s failure-mode rationale).
177async fn run_reset(
178    workspace: &Path,
179    config: &DjogiConfig,
180    maintenance_database: &str,
181    confirmed: bool,
182    allow_checksum_drift_reset: bool,
183    runner_identity: Option<djogi::migrate::RunnerIdentity>,
184) -> i32 {
185    // Version preflight — verify PostgreSQL >= 18 on the target cluster
186    // before any destructive work.
187    // Connect to the MAINTENANCE database, not the application database,
188    // because db reset drops the application database — it may not exist
189    // at preflight time. Both databases are on the same cluster and
190    // share the same server_version_num.
191    let maintenance_url =
192        djogi::migrate::replace_db_in_url(&config.database.url, maintenance_database);
193    let preflight_url = maintenance_url.as_deref().unwrap_or(&config.database.url);
194    let preflight_pool = match djogi::pg::pool::DjogiPool::connect(preflight_url).await {
195        Ok(p) => p,
196        Err(e) => {
197            eprintln!("djogi db reset: support boundary: connect to maintenance DB: {e}");
198            return 1;
199        }
200    };
201    if let Err(e) = djogi::pg::preflight::check_postgres_version(&preflight_pool).await {
202        crate::print_support_boundary_error("db reset", &e);
203        return 2;
204    }
205    drop(preflight_pool);
206
207    let audit_pool = resolve_audit_pool_best_effort(config).await;
208    let req = ResetRequest {
209        workspace_root: workspace,
210        database_url: &config.database.url,
211        profile: &config.profile,
212        confirmed,
213        allow_checksum_drift_reset,
214        maintenance_database,
215        migrate_config: djogi::config::MigrateConfig {
216            concurrent_warn_relpages: config.migrate.concurrent_warn_relpages,
217            strict_concurrent_warnings: config.migrate.strict_concurrent_warnings,
218            pk_flip_long_tx_threshold_secs: config.migrate.pk_flip_long_tx_threshold_secs,
219            pk_flip_join_table_option: config.migrate.pk_flip_join_table_option,
220        },
221        audit_pool,
222        runner_identity,
223    };
224    match reset_app_database(req).await {
225        Ok(report) => {
226            print_reset_report(&report);
227            0
228        }
229        Err(ResetError::Refused(refusal)) => {
230            eprintln!("djogi db reset: refused — {refusal}");
231            // Use a distinct exit code (2) for refusal so scripts can
232            // distinguish "policy refused" from "underlying SQL
233            // failure". Mirrors clap's argument-error convention.
234            2
235        }
236        Err(other) => {
237            eprintln!("djogi db reset: {other}");
238            1
239        }
240    }
241}
242
243/// Best-effort audit-pool construction for the `db reset` replay path
244/// (issue #118).
245/// Returns `Some(pool)` when the operator's environment / `Djogi.toml`
246/// resolves to an audit DB URL and the pool can be constructed from
247/// that URL. Returns `None` (with a `tracing::warn!`) on any of:
248/// - URL resolution failure (no path component to splice; no
249///   `CRUD_LOG_URL` / `[database].crud_log_url` override; self-audit
250///   refusal because `database.url` already ends in `/crud_log`).
251/// - Syntactically invalid pool configuration or immediate pool
252///   construction failure.
253///   **Why best-effort.** The audit overlay is a defence-in-depth
254///   mechanism: an audit row exists so a future `db reset` cannot erase
255///   the migration history. Refusing the destructive `db reset` over an
256///   audit-side configuration glitch would invert the priority — the
257///   operator's recovery path (re-run reset to rebuild the DB) gets
258///   blocked by a sibling-DB outage. The runner's own audit-write loop
259///   already follows the same stance: a `Some(audit_pool)` whose first
260///   `INSERT` fails is logged + skipped without rolling back the
261///   committed app DDL (see [`super::audit`]'s
262///   `record_ddl_audit_for_plan` doc).
263///   **Operator visibility.** Degradation paths detected before replay
264///   print a warning to stderr and also emit a `tracing::warn!` with the
265///   offending URL (when known). A syntactically valid but unreachable
266///   audit DB may not fail until the runner's first audit insert; that
267///   later path follows the runner's existing best-effort tracing warning.
268async fn resolve_audit_pool_best_effort(config: &DjogiConfig) -> Option<deadpool_postgres::Pool> {
269    let url = match djogi::migrate::resolve_audit_url(config) {
270        Ok(u) => u,
271        Err(e) => {
272            eprintln!(
273                "djogi db reset: warning — audit-pool URL resolution failed; \
274                 proceeding without djogi_ddl_audit rows: {e}"
275            );
276            tracing::warn!(
277                target: "djogi::cli::db::reset",
278                error = %e,
279                "audit-pool URL resolution failed; db reset will proceed without writing \
280                 djogi_ddl_audit rows"
281            );
282            return None;
283        }
284    };
285    match djogi::migrate::build_audit_pool(&url).await {
286        Ok(pool) => Some(pool),
287        Err(e) => {
288            eprintln!(
289                "djogi db reset: warning — audit-pool construction failed for `{url}`; \
290                 proceeding without djogi_ddl_audit rows: {e}"
291            );
292            tracing::warn!(
293                target: "djogi::cli::db::reset",
294                audit_url = %url,
295                error = %e,
296                "audit-pool construction failed; db reset will proceed without writing \
297                 djogi_ddl_audit rows"
298            );
299            None
300        }
301    }
302}
303
304/// Print the post-reset report to stdout. Operators see one line per
305/// replayed migration plus a final tally.
306fn print_reset_report(report: &ResetReport) {
307    println!(
308        "db reset complete — recreated database `{}`",
309        report.database
310    );
311    if report.replayed_versions.is_empty() {
312        println!("  no committed migrations replayed");
313        return;
314    }
315    for entry in &report.replayed_versions {
316        let app = if entry.bucket.app.is_empty() {
317            "_global_"
318        } else {
319            entry.bucket.app.as_str()
320        };
321        println!(
322            "  replayed {database}/{app}: {version}",
323            database = entry.bucket.database,
324            version = entry.version,
325        );
326    }
327    println!(
328        "  total: {} migration(s) replayed",
329        report.replayed_versions.len()
330    );
331}
332
333/// Interactive y/N prompt. Reads one line from stdin; returns `Ok(true)`
334/// only on a `y` / `yes` answer (case-insensitive ASCII). Anything
335/// else (including EOF, empty input, or `n` / `no`) returns `Ok(false)`.
336fn interactive_confirm(database_url: &str) -> std::io::Result<bool> {
337    let stderr = std::io::stderr();
338    let mut handle = stderr.lock();
339    writeln!(
340        handle,
341        "WARNING: db reset will DROP and RECREATE the application database \
342         pointed at by DATABASE_URL ({database_url}); every row will be lost. \
343         Migrations under `migrations/<database>/` will be replayed onto the \
344         freshly-created database. This action cannot be undone."
345    )?;
346    write!(handle, "Type `yes` to confirm, anything else to abort: ")?;
347    handle.flush()?;
348    let stdin = std::io::stdin();
349    let mut line = String::new();
350    stdin.lock().read_line(&mut line)?;
351    Ok(matches!(
352        line.trim().to_ascii_lowercase().as_str(),
353        "y" | "yes"
354    ))
355}
356
357// ── db seed ───────────────────────────────────────────────────────────────
358
359/// `djogi db seed` entry point.
360pub fn seed_cmd(
361    database: String,
362    allow_non_localhost: bool,
363    workspace: Option<PathBuf>,
364) -> ExitCode {
365    let workspace = resolve_workspace(workspace);
366    let config = match DjogiConfig::load_from_workspace(&workspace) {
367        Ok(c) => c,
368        Err(e) => {
369            eprintln!("djogi db seed: config load: {e}");
370            return ExitCode::from(1);
371        }
372    };
373
374    let runtime = match build_runtime("db seed") {
375        Ok(r) => r,
376        Err(code) => return code,
377    };
378    let exit = runtime
379        .block_on(async { run_seed(&workspace, &config, &database, allow_non_localhost).await });
380    ExitCode::from(exit as u8)
381}
382
383/// Async body of [`seed_cmd`]. Returns the desired exit code.
384/// **Per-database routing.** The `--database <name>` flag selects
385/// BOTH the `seeds/<name>/` directory the runner walks AND the
386/// connection URL the SQL fires against. The
387/// CLI derives the per-database URL by splicing `<name>` into
388/// `database.url`'s path component (via
389/// [`djogi::migrate::derive_per_database_url`]) — without that
390/// splice, `db seed --database crud_log` would silently run
391/// crud-log seed SQL against the application database. A malformed
392/// application URL (no path component) is surfaced as a typed
393/// [`SeedError::MalformedApplicationUrl`] rather than a default to
394/// the wrong DB.
395async fn run_seed(
396    workspace: &Path,
397    config: &DjogiConfig,
398    database: &str,
399    allow_non_localhost: bool,
400) -> i32 {
401    // Splice the operator's `--database <name>` into the application
402    // URL. The result is the connection target AND the URL the
403    // localhost gate inside `run_seeds` evaluates against — both
404    // gate and SQL execution stay on the same database.
405    // typed `SeedError::MalformedApplicationUrl` variant rather than
406    // a bare `eprintln!`. The variant was previously dead — the CLI
407    // now constructs it explicitly so the error path is operator-
408    // actionable AND the variant has a real call site.
409    let routed_url = match djogi::migrate::derive_per_database_url(&config.database.url, database) {
410        Some(u) => u,
411        None => {
412            let err = SeedError::MalformedApplicationUrl {
413                application_url: config.database.url.clone(),
414            };
415            eprintln!("djogi db seed: {err} (--database `{database}`)");
416            return 1;
417        }
418    };
419
420    // Build a context against the routed (per-database) URL.
421    let pool = match djogi::pg::pool::DjogiPool::connect(&routed_url).await {
422        Ok(p) => p,
423        Err(e) => {
424            eprintln!("djogi db seed: connect: {e}");
425            return 1;
426        }
427    };
428    if let Err(e) = djogi::pg::preflight::check_postgres_version(&pool).await {
429        crate::print_support_boundary_error("db seed", &e);
430        return 2;
431    }
432    let mut ctx = djogi::context::DjogiContext::from_pool(pool);
433
434    match run_seeds(
435        &mut ctx,
436        workspace,
437        database,
438        &routed_url,
439        allow_non_localhost,
440    )
441    .await
442    {
443        Ok(report) => {
444            print_seed_report(&report);
445            0
446        }
447        Err(SeedError::LocalhostGate { database_url }) => {
448            eprintln!(
449                "djogi db seed: refused — DATABASE_URL `{database_url}` is not \
450                 localhost; pass `--allow-non-localhost` to override"
451            );
452            2
453        }
454        Err(other) => {
455            eprintln!("djogi db seed: {other}");
456            1
457        }
458    }
459}
460
461fn print_seed_report(report: &SeedReport) {
462    if report.entries.is_empty() {
463        println!("db seed: no seeds discovered");
464        return;
465    }
466    let mut applied = 0usize;
467    let mut skipped = 0usize;
468    for entry in &report.entries {
469        let label = match entry.outcome {
470            SeedOutcome::Applied => {
471                applied += 1;
472                "applied"
473            }
474            SeedOutcome::SkippedAlreadyApplied => {
475                skipped += 1;
476                "skipped (already applied)"
477            }
478        };
479        println!("  {label:>30}  {name}", name = entry.seed_name);
480    }
481    println!("db seed: {applied} applied, {skipped} skipped");
482}
483
484// ── db cleanup-test-dbs ───────────────────────────────────────────────────
485
486/// `djogi db cleanup-test-dbs` entry point — drops orphaned
487/// `djogi_test_<uuid>` databases left behind by `#[djogi_test]` runs
488/// killed by SIGKILL / OOM / panic-after-spawn before
489/// [`djogi::testing::teardown_test_db`] could fire.
490/// Triple-gated identical to `db reset`:
491/// 1. **Localhost.** `DjogiConfig::database.url` MUST resolve to
492///    `127.0.0.1` / `localhost` / `[::1]`, unless the operator passed
493///    `--allow-non-localhost` to override (parity with `db seed`'s
494///    lighter gate — sometimes operators run a remote dev cluster).
495/// 2. **Non-production.** `Djogi.toml::profile` MUST NOT equal
496///    `"production"`. Mirrors `db reset`'s second gate so the same
497///    rules govern any operation that issues `DROP DATABASE`.
498/// 3. **Confirmation.** `--yes` is required, unless `--dry-run` is
499///    passed. `--dry-run` lists candidates without dropping; no
500///    confirmation needed because no side effect occurs.
501///    `maintenance_database` defaults to `"postgres"` — the conventional
502///    administrative DB present on every cluster — and is spliced into
503///    `database.url`'s path component to produce the admin connection
504///    URL (the application database itself can't drop other databases on
505///    the same cluster).
506///    Exit codes match the `db` matrix at the top of this module: `0` on
507///    success, `1` on runtime / SQL / connect failure, `2` on gate
508///    refusal (non-localhost without override, production profile,
509///    missing `--yes`).
510pub fn cleanup_test_dbs_cmd(
511    dry_run: bool,
512    yes: bool,
513    maintenance_database: String,
514    allow_non_localhost: bool,
515    workspace: Option<PathBuf>,
516) -> ExitCode {
517    let workspace = resolve_workspace(workspace);
518    let config = match DjogiConfig::load_from_workspace(&workspace) {
519        Ok(c) => c,
520        Err(e) => {
521            eprintln!("djogi db cleanup-test-dbs: config load: {e}");
522            return ExitCode::from(1);
523        }
524    };
525
526    // Gate 1 — localhost. The cleanup issues `DROP DATABASE` against
527    // every `djogi_test_*` candidate; the localhost requirement
528    // ensures the destructive surface stays on the operator's own
529    // cluster unless they explicitly opt out via
530    // `--allow-non-localhost`.
531    if !allow_non_localhost && !djogi::migrate::is_localhost_connection(&config.database.url) {
532        eprintln!(
533            "djogi db cleanup-test-dbs: refused — DATABASE_URL `{}` is not \
534             localhost; pass `--allow-non-localhost` to override",
535            config.database.url
536        );
537        return ExitCode::from(2);
538    }
539
540    // Gate 2 — production profile. Identical predicate to `db reset`'s
541    // production gate so the rules governing destructive ops stay
542    // consistent across the `db` family.
543    if config.profile == "production" {
544        eprintln!(
545            "djogi db cleanup-test-dbs: refused — Djogi.toml::profile = `{}`; \
546             refusing to run on a production profile",
547            config.profile
548        );
549        return ExitCode::from(2);
550    }
551
552    // Gate 3 — explicit confirmation, unless `--dry-run` is in effect.
553    // `--dry-run` performs no DROPs, so confirmation is moot.
554    if !dry_run && !yes {
555        eprintln!(
556            "djogi db cleanup-test-dbs: refused — pass `--yes` to confirm, \
557             or `--dry-run` to list candidates without dropping"
558        );
559        return ExitCode::from(2);
560    }
561
562    // Validate the maintenance database name before splicing it into
563    // a URL — the same byte-level grammar `db reset` enforces. Strict
564    // Postgres-identifier rules: ASCII letter or underscore followed
565    // by ASCII alphanumerics or underscores, up to 63 bytes.
566    if !is_valid_pg_identifier(&maintenance_database) {
567        eprintln!(
568            "djogi db cleanup-test-dbs: invalid maintenance database name `{maintenance_database}`"
569        );
570        return ExitCode::from(1);
571    }
572
573    // Splice the maintenance database into the application URL. The
574    // application URL's path component points at the per-app database
575    // (e.g. `main`); cleanup must connect to the cluster's admin DB
576    // (default `postgres`) to issue `DROP DATABASE` against the
577    // orphaned `djogi_test_*` peers.
578    let admin_url = match djogi::migrate::derive_per_database_url(
579        &config.database.url,
580        &maintenance_database,
581    ) {
582        Some(u) => u,
583        None => {
584            eprintln!(
585                "djogi db cleanup-test-dbs: malformed application URL `{}` — \
586                 cannot derive maintenance connection URL",
587                config.database.url
588            );
589            return ExitCode::from(1);
590        }
591    };
592
593    let runtime = match build_runtime("db cleanup-test-dbs") {
594        Ok(r) => r,
595        Err(code) => return code,
596    };
597    let exit = runtime.block_on(async { run_cleanup_test_dbs(&admin_url, dry_run).await });
598    ExitCode::from(exit as u8)
599}
600
601/// Async body of [`cleanup_test_dbs_cmd`]. Returns the desired exit
602/// code.
603async fn run_cleanup_test_dbs(admin_url: &str, dry_run: bool) -> i32 {
604    if dry_run {
605        match djogi::testing::list_orphaned_test_databases(admin_url).await {
606            Ok(candidates) => {
607                if candidates.is_empty() {
608                    println!("db cleanup-test-dbs (dry run): no orphaned test databases found");
609                } else {
610                    println!(
611                        "db cleanup-test-dbs (dry run): {} candidate(s):",
612                        candidates.len()
613                    );
614                    for name in &candidates {
615                        println!("  {name}");
616                    }
617                }
618                0
619            }
620            Err(e) => {
621                eprintln!("djogi db cleanup-test-dbs: {e}");
622                1
623            }
624        }
625    } else {
626        match djogi::testing::cleanup_orphaned_test_databases(admin_url).await {
627            Ok(dropped) => {
628                if dropped.is_empty() {
629                    println!("db cleanup-test-dbs: no orphaned test databases dropped");
630                } else {
631                    println!(
632                        "db cleanup-test-dbs: dropped {} database(s):",
633                        dropped.len()
634                    );
635                    for name in &dropped {
636                        println!("  {name}");
637                    }
638                }
639                0
640            }
641            Err(e) => {
642                eprintln!("djogi db cleanup-test-dbs: {e}");
643                1
644            }
645        }
646    }
647}
648
649/// Strict Postgres-identifier check used for the
650/// `--maintenance-database` argument: ASCII letter or underscore
651/// followed by ASCII alphanumerics or underscores, up to 63 bytes
652/// total. Mirrors the grammar `djogi::migrate::reset` enforces on the
653/// equivalent argument; kept inline (rather than re-exporting the
654/// crate-private helper) so the CLI's defence-in-depth is self
655/// contained at this layer.
656fn is_valid_pg_identifier(name: &str) -> bool {
657    let bytes = name.as_bytes();
658    if bytes.is_empty() || bytes.len() > 63 {
659        return false;
660    }
661    let first = bytes[0];
662    if !(first.is_ascii_alphabetic() || first == b'_') {
663        return false;
664    }
665    for &b in &bytes[1..] {
666        if !(b.is_ascii_alphanumeric() || b == b'_') {
667            return false;
668        }
669    }
670    true
671}
672
673// ── docs ──────────────────────────────────────────────────────────────────
674
675/// `djogi docs` entry point.
676/// `output` defaults to `target/djogi-docs/` under the workspace. The
677/// per-model files are written into `<output>/<app>/<Model>.md` and a
678/// top-level `<output>/README.md` indexes them.
679pub fn docs_cmd(
680    provider: &dyn DescriptorProvider,
681    output: Option<PathBuf>,
682    workspace: Option<PathBuf>,
683) -> ExitCode {
684    // §5.6 — docs hard-refuses on zero descriptors (behavior change:
685    // was exit 0 rendering an empty README; now exit 2 + dual-cause).
686    if provider.models().is_empty() {
687        crate::print_zero_descriptor_diagnostic("docs");
688        return ExitCode::from(2);
689    }
690    let workspace = resolve_workspace(workspace);
691    let output = output.unwrap_or_else(|| workspace.join("target").join("djogi-docs"));
692    // 4 — load `<workspace>/.djogi/intent.json` if
693    // present. Absent file → `Ok(None)`, which `generate_docs_with_provider`
694    // treats as "no intent merge"; a malformed file fails the
695    // command with a clear error before any docs files are
696    // written, so adopters notice typos instead of silently
697    // losing their rationale.
698    let intent = match djogi::intent::load(&workspace) {
699        Ok(maybe) => maybe,
700        Err(e) => {
701            eprintln!("djogi docs: {e}");
702            return ExitCode::from(1);
703        }
704    };
705    match generate_docs_with_provider(provider, &output, intent.as_ref()) {
706        Ok(report) => {
707            println!(
708                "docs: rendered {n} model page(s) into {path}",
709                n = report.models_rendered,
710                path = report.output_root.display(),
711            );
712            ExitCode::from(0)
713        }
714        Err(e) => {
715            eprintln!("djogi docs: {e}");
716            ExitCode::from(1)
717        }
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    use std::fs;
725    use std::sync::atomic::{AtomicUsize, Ordering};
726
727    struct DatabaseUrlEnvGuard {
728        _lock: std::sync::MutexGuard<'static, ()>,
729        prior: Option<String>,
730    }
731
732    impl DatabaseUrlEnvGuard {
733        fn new() -> Self {
734            Self {
735                _lock: crate::test_env_lock(),
736                prior: std::env::var("DATABASE_URL").ok(),
737            }
738        }
739
740        fn set(&self, value: &str) {
741            unsafe { std::env::set_var("DATABASE_URL", value) };
742        }
743
744        fn remove(&self) {
745            unsafe { std::env::remove_var("DATABASE_URL") };
746        }
747    }
748
749    impl Drop for DatabaseUrlEnvGuard {
750        fn drop(&mut self) {
751            match &self.prior {
752                Some(value) => unsafe { std::env::set_var("DATABASE_URL", value) },
753                None => unsafe { std::env::remove_var("DATABASE_URL") },
754            }
755        }
756    }
757
758    fn without_database_url<T>(f: impl FnOnce() -> T) -> T {
759        let env_guard = DatabaseUrlEnvGuard::new();
760        env_guard.remove();
761        f()
762    }
763
764    #[test]
765    fn database_url_env_guard_restores_prior_value() {
766        let env_guard = DatabaseUrlEnvGuard::new();
767        let expected = env_guard.prior.clone();
768        let next = if expected.as_deref() == Some("postgres://temporary/test") {
769            "postgres://temporary/other"
770        } else {
771            "postgres://temporary/test"
772        };
773        env_guard.set(next);
774        drop(env_guard);
775        assert_eq!(std::env::var("DATABASE_URL").ok(), expected);
776    }
777
778    fn temp_workspace(tag: &str) -> PathBuf {
779        static COUNTER: AtomicUsize = AtomicUsize::new(0);
780        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
781        let nanos = std::time::SystemTime::now()
782            .duration_since(std::time::UNIX_EPOCH)
783            .unwrap()
784            .as_nanos();
785        let p = std::env::temp_dir().join(format!("djogi-cli-db-{tag}-{nanos}-{n}"));
786        fs::create_dir_all(&p).unwrap();
787        p
788    }
789
790    /// `db reset` without node identity must refuse before any prompt,
791    /// connection, or destructive I/O.
792    #[test]
793    fn reset_cmd_refuses_when_identity_is_missing() {
794        let work = temp_workspace("reset_remote");
795        let toml = "[database]\nurl = \"postgres://prod.example.com/main\"\n\
796                    max_connections = 1\ndev_mode = false\n\
797                    [server]\nhost = \"127.0.0.1\"\nport = 1234\n";
798        fs::write(work.join("Djogi.toml"), toml).unwrap();
799        let exit = without_database_url(|| {
800            reset_cmd(
801                true,                   // yes
802                false,                  // allow_checksum_drift_reset
803                "postgres".to_string(), // maintenance_database
804                Some(work.clone()),     // workspace
805                None,                   // node_id
806                false,                  // single_node_dev
807            )
808        });
809        assert_eq!(
810            exit,
811            ExitCode::from(2),
812            "missing identity must refuse before localhost gating"
813        );
814        let _ = fs::remove_dir_all(&work);
815    }
816
817    /// `db reset --single-node-dev` against a production profile must
818    /// refuse during CLI identity resolution before any runtime or SQL
819    /// work starts.
820    #[test]
821    fn reset_cmd_refuses_single_node_dev_in_production_profile() {
822        let work = temp_workspace("reset_prod");
823        let toml = "profile = \"production\"\n\
824                    [database]\nurl = \"postgres://localhost/main\"\n\
825                    max_connections = 1\ndev_mode = false\n\
826                    [server]\nhost = \"127.0.0.1\"\nport = 1234\n";
827        fs::write(work.join("Djogi.toml"), toml).unwrap();
828        let exit = without_database_url(|| {
829            reset_cmd(
830                true,                   // yes
831                false,                  // allow_checksum_drift_reset
832                "postgres".to_string(), // maintenance_database
833                Some(work.clone()),     // workspace
834                None,                   // node_id
835                true,                   // single_node_dev
836            )
837        });
838        assert_eq!(
839            exit,
840            ExitCode::from(2),
841            "production profile must refuse single-node-dev during identity resolution"
842        );
843        let _ = fs::remove_dir_all(&work);
844    }
845
846    // ── cleanup-test-dbs ────────────────────────────────────────────
847
848    /// Non-localhost URL refuses with exit code 2 when
849    /// `--allow-non-localhost` is omitted, regardless of `--yes` or
850    /// `--dry-run`. Mirrors `db reset`'s localhost gate.
851    #[test]
852    fn cleanup_test_dbs_refuses_non_localhost_without_override() {
853        let work = temp_workspace("cleanup_remote");
854        let toml = "[database]\nurl = \"postgres://prod.example.com/main\"\n\
855                    max_connections = 1\ndev_mode = false\n\
856                    [server]\nhost = \"127.0.0.1\"\nport = 1234\n";
857        fs::write(work.join("Djogi.toml"), toml).unwrap();
858        // `--yes` set, `--allow-non-localhost` NOT set, `--dry-run`
859        // NOT set — localhost gate must refuse first.
860        let exit = without_database_url(|| {
861            cleanup_test_dbs_cmd(
862                false,
863                true,
864                "postgres".to_string(),
865                false,
866                Some(work.clone()),
867            )
868        });
869        assert_eq!(
870            exit,
871            ExitCode::from(2),
872            "non-localhost without override must refuse"
873        );
874        let _ = fs::remove_dir_all(&work);
875    }
876
877    /// Production profile refuses (exit 2) even with localhost +
878    /// `--yes`. Identical predicate to `db reset`'s production gate.
879    #[test]
880    fn cleanup_test_dbs_refuses_on_production_profile() {
881        let work = temp_workspace("cleanup_prod");
882        let toml = "profile = \"production\"\n\
883                    [database]\nurl = \"postgres://localhost/main\"\n\
884                    max_connections = 1\ndev_mode = false\n\
885                    [server]\nhost = \"127.0.0.1\"\nport = 1234\n";
886        fs::write(work.join("Djogi.toml"), toml).unwrap();
887        let exit = without_database_url(|| {
888            cleanup_test_dbs_cmd(
889                false,
890                true,
891                "postgres".to_string(),
892                false,
893                Some(work.clone()),
894            )
895        });
896        assert_eq!(exit, ExitCode::from(2), "production must refuse");
897        let _ = fs::remove_dir_all(&work);
898    }
899
900    /// Localhost + non-production + neither `--yes` nor `--dry-run`
901    /// must refuse with exit code 2 (missing confirmation).
902    #[test]
903    fn cleanup_test_dbs_refuses_without_yes_or_dry_run() {
904        let work = temp_workspace("cleanup_no_yes");
905        let toml = "[database]\nurl = \"postgres://localhost/main\"\n\
906                    max_connections = 1\ndev_mode = false\n\
907                    [server]\nhost = \"127.0.0.1\"\nport = 1234\n";
908        fs::write(work.join("Djogi.toml"), toml).unwrap();
909        let exit = without_database_url(|| {
910            cleanup_test_dbs_cmd(
911                false,
912                false,
913                "postgres".to_string(),
914                false,
915                Some(work.clone()),
916            )
917        });
918        assert_eq!(
919            exit,
920            ExitCode::from(2),
921            "missing --yes without --dry-run must refuse"
922        );
923        let _ = fs::remove_dir_all(&work);
924    }
925
926    /// Invalid maintenance database name (e.g. SQL-injection
927    /// candidate) is refused at the CLI before any connection
928    /// attempt. Returns exit code 1 — argument validation, not gate
929    /// refusal.
930    #[test]
931    fn cleanup_test_dbs_rejects_invalid_maintenance_database() {
932        let work = temp_workspace("cleanup_bad_maint");
933        let toml = "[database]\nurl = \"postgres://localhost/main\"\n\
934                    max_connections = 1\ndev_mode = false\n\
935                    [server]\nhost = \"127.0.0.1\"\nport = 1234\n";
936        fs::write(work.join("Djogi.toml"), toml).unwrap();
937        let exit = without_database_url(|| {
938            cleanup_test_dbs_cmd(
939                false,
940                true,
941                "'; DROP DATABASE main; --".to_string(),
942                false,
943                Some(work.clone()),
944            )
945        });
946        assert_eq!(
947            exit,
948            ExitCode::from(1),
949            "invalid maintenance DB name must reject"
950        );
951        let _ = fs::remove_dir_all(&work);
952    }
953
954    /// `is_valid_pg_identifier` accepts typical names and rejects
955    /// pathological ones — defence-in-depth check on the inline
956    /// validator.
957    #[test]
958    fn is_valid_pg_identifier_byte_grammar() {
959        assert!(is_valid_pg_identifier("postgres"));
960        assert!(is_valid_pg_identifier("rdsadmin"));
961        assert!(is_valid_pg_identifier("_under"));
962        assert!(is_valid_pg_identifier("a"));
963        assert!(is_valid_pg_identifier("a_1_b"));
964
965        assert!(!is_valid_pg_identifier(""));
966        assert!(!is_valid_pg_identifier("1starts_with_digit"));
967        assert!(!is_valid_pg_identifier("has space"));
968        assert!(!is_valid_pg_identifier("'; DROP TABLE foo; --"));
969        // 64 bytes — one over the Postgres identifier-length cap.
970        assert!(!is_valid_pg_identifier(&"a".repeat(64)));
971        assert!(is_valid_pg_identifier(&"a".repeat(63)));
972    }
973
974    /// `docs` against a binary with zero registered models refuses with
975    /// exit 2 + the dual-cause diagnostic (#370 behavior change: was
976    /// exit 0 rendering an empty README).
977    #[test]
978    fn docs_cmd_against_empty_provider_refuses() {
979        struct EmptyProvider;
980        impl djogi::migrate::DescriptorProvider for EmptyProvider {
981            fn models(&self) -> Vec<&'static djogi::descriptor::ModelDescriptor> {
982                Vec::new()
983            }
984            fn enums(&self) -> Vec<&'static djogi::descriptor::EnumDescriptor> {
985                Vec::new()
986            }
987            fn apps(&self) -> &'static [djogi::apps::AppDescriptor] {
988                djogi::apps::AppRegistry::all()
989            }
990            fn deferrability_specs(&self) -> Vec<&'static djogi::descriptor::DeferrabilitySpec> {
991                Vec::new()
992            }
993        }
994        let work = temp_workspace("docs_empty_refusal");
995        let out = work.join("target/djogi-docs");
996        let exit = docs_cmd(&EmptyProvider, Some(out.clone()), Some(work.clone()));
997        assert_eq!(exit, ExitCode::from(2));
998        // No README is rendered on refusal.
999        assert!(
1000            !out.join("README.md").exists(),
1001            "refusal must not render docs"
1002        );
1003        let _ = fs::remove_dir_all(&work);
1004    }
1005}