1use 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
43fn resolve_workspace(workspace: Option<PathBuf>) -> PathBuf {
47 workspace.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
48}
49
50fn 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
63pub 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 let confirmed = if yes {
94 true
95 } else {
96 match interactive_confirm(&config.database.url) {
97 Ok(c) => c,
98 Err(_) => {
99 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
127async 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 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 2
199 }
200 Err(other) => {
201 eprintln!("djogi db reset: {other}");
202 1
203 }
204 }
205}
206
207async 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
268fn 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
297fn 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
321pub 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
347async fn run_seed(
360 workspace: &Path,
361 config: &DjogiConfig,
362 database: &str,
363 allow_non_localhost: bool,
364) -> i32 {
365 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 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
448pub 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 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 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 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 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 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
565async 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
613fn 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
637pub fn docs_cmd(
644 provider: &dyn DescriptorProvider,
645 output: Option<PathBuf>,
646 workspace: Option<PathBuf>,
647) -> ExitCode {
648 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 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 #[test]
706 fn reset_cmd_refuses_when_not_confirmed_and_url_remote() {
707 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 let prior = std::env::var("DATABASE_URL").ok();
719 unsafe { std::env::remove_var("DATABASE_URL") };
721
722 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 #[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 #[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 unsafe { std::env::remove_var("DATABASE_URL") };
772
773 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 #[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 #[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 #[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 #[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 assert!(!is_valid_pg_identifier(&"a".repeat(64)));
907 assert!(is_valid_pg_identifier(&"a".repeat(63)));
908 }
909
910 #[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 assert!(
936 !out.join("README.md").exists(),
937 "refusal must not render docs"
938 );
939 let _ = fs::remove_dir_all(&work);
940 }
941}