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