Skip to main content

djogi_cli/
db.rs

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