1use 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
50fn resolve_workspace(workspace: Option<PathBuf>) -> PathBuf {
54 workspace.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
55}
56
57fn 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
70pub 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 let confirmed = if yes {
103 true
104 } else {
105 match interactive_confirm(&config.database.url) {
106 Ok(c) => c,
107 Err(_) => {
108 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
136async 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 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 2
211 }
212 Err(other) => {
213 eprintln!("djogi db reset: {other}");
214 1
215 }
216 }
217}
218
219async 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
284fn 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
313fn 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
337pub 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
363async fn run_seed(
377 workspace: &Path,
378 config: &DjogiConfig,
379 database: &str,
380 allow_non_localhost: bool,
381) -> i32 {
382 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 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
467pub 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 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 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 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 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 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
588async 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
636fn 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
660pub fn docs_cmd(
668 provider: &dyn DescriptorProvider,
669 output: Option<PathBuf>,
670 workspace: Option<PathBuf>,
671) -> ExitCode {
672 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 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 #[test]
730 fn reset_cmd_refuses_when_not_confirmed_and_url_remote() {
731 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 let prior = std::env::var("DATABASE_URL").ok();
743 unsafe { std::env::remove_var("DATABASE_URL") };
745
746 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 #[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 #[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 unsafe { std::env::remove_var("DATABASE_URL") };
796
797 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 #[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 #[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 #[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 #[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 assert!(!is_valid_pg_identifier(&"a".repeat(64)));
931 assert!(is_valid_pg_identifier(&"a".repeat(63)));
932 }
933
934 #[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 assert!(
960 !out.join("README.md").exists(),
961 "refusal must not render docs"
962 );
963 let _ = fs::remove_dir_all(&work);
964 }
965}