use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use djogi::config::DjogiConfig;
use djogi::migrate::{
DescriptorProvider, ResetError, ResetReport, ResetRequest, SeedError, SeedOutcome, SeedReport,
generate_docs_with_provider, reset_app_database, run_seeds,
};
fn resolve_workspace(workspace: Option<PathBuf>) -> PathBuf {
workspace.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
}
fn build_runtime(label: &str) -> Result<tokio::runtime::Runtime, ExitCode> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| {
eprintln!("djogi {label}: tokio runtime: {e}");
ExitCode::from(1)
})
}
pub fn reset_cmd(
yes: bool,
allow_checksum_drift_reset: bool,
maintenance_database: String,
workspace: Option<PathBuf>,
) -> ExitCode {
let workspace = resolve_workspace(workspace);
let config = match DjogiConfig::load_from_workspace(&workspace) {
Ok(c) => c,
Err(e) => {
eprintln!("djogi db reset: config load: {e}");
return ExitCode::from(1);
}
};
let confirmed = if yes {
true
} else {
match interactive_confirm(&config.database.url) {
Ok(c) => c,
Err(_) => {
eprintln!(
"djogi db reset: failed to read confirmation; \
refusing without an explicit `--yes`"
);
return ExitCode::from(1);
}
}
};
let runtime = match build_runtime("db reset") {
Ok(r) => r,
Err(code) => return code,
};
let exit = runtime.block_on(async {
run_reset(
&workspace,
&config,
&maintenance_database,
confirmed,
allow_checksum_drift_reset,
)
.await
});
ExitCode::from(exit as u8)
}
async fn run_reset(
workspace: &Path,
config: &DjogiConfig,
maintenance_database: &str,
confirmed: bool,
allow_checksum_drift_reset: bool,
) -> i32 {
let maintenance_url =
djogi::migrate::replace_db_in_url(&config.database.url, maintenance_database);
let preflight_url = maintenance_url.as_deref().unwrap_or(&config.database.url);
let preflight_pool = match djogi::pg::pool::DjogiPool::connect(preflight_url).await {
Ok(p) => p,
Err(e) => {
eprintln!("djogi db reset: support boundary: connect to maintenance DB: {e}");
return 1;
}
};
if let Err(e) = djogi::pg::preflight::check_postgres_version(&preflight_pool).await {
crate::print_support_boundary_error("db reset", &e);
return 2;
}
drop(preflight_pool);
let audit_pool = resolve_audit_pool_best_effort(config).await;
let req = ResetRequest {
workspace_root: workspace,
database_url: &config.database.url,
profile: &config.profile,
confirmed,
allow_checksum_drift_reset,
maintenance_database,
migrate_config: djogi::config::MigrateConfig {
concurrent_warn_relpages: config.migrate.concurrent_warn_relpages,
strict_concurrent_warnings: config.migrate.strict_concurrent_warnings,
pk_flip_long_tx_threshold_secs: config.migrate.pk_flip_long_tx_threshold_secs,
pk_flip_join_table_option: config.migrate.pk_flip_join_table_option,
},
audit_pool,
};
match reset_app_database(req).await {
Ok(report) => {
print_reset_report(&report);
0
}
Err(ResetError::Refused(refusal)) => {
eprintln!("djogi db reset: refused — {refusal}");
2
}
Err(other) => {
eprintln!("djogi db reset: {other}");
1
}
}
}
async fn resolve_audit_pool_best_effort(config: &DjogiConfig) -> Option<deadpool_postgres::Pool> {
let url = match djogi::migrate::resolve_audit_url(config) {
Ok(u) => u,
Err(e) => {
eprintln!(
"djogi db reset: warning — audit-pool URL resolution failed; \
proceeding without djogi_ddl_audit rows: {e}"
);
tracing::warn!(
target: "djogi::cli::db::reset",
error = %e,
"audit-pool URL resolution failed; db reset will proceed without writing \
djogi_ddl_audit rows"
);
return None;
}
};
match djogi::migrate::build_audit_pool(&url).await {
Ok(pool) => Some(pool),
Err(e) => {
eprintln!(
"djogi db reset: warning — audit-pool construction failed for `{url}`; \
proceeding without djogi_ddl_audit rows: {e}"
);
tracing::warn!(
target: "djogi::cli::db::reset",
audit_url = %url,
error = %e,
"audit-pool construction failed; db reset will proceed without writing \
djogi_ddl_audit rows"
);
None
}
}
}
fn print_reset_report(report: &ResetReport) {
println!(
"db reset complete — recreated database `{}`",
report.database
);
if report.replayed_versions.is_empty() {
println!(" no committed migrations replayed");
return;
}
for entry in &report.replayed_versions {
let app = if entry.bucket.app.is_empty() {
"_global_"
} else {
entry.bucket.app.as_str()
};
println!(
" replayed {database}/{app}: {version}",
database = entry.bucket.database,
version = entry.version,
);
}
println!(
" total: {} migration(s) replayed",
report.replayed_versions.len()
);
}
fn interactive_confirm(database_url: &str) -> std::io::Result<bool> {
let stderr = std::io::stderr();
let mut handle = stderr.lock();
writeln!(
handle,
"WARNING: db reset will DROP and RECREATE the application database \
pointed at by DATABASE_URL ({database_url}); every row will be lost. \
Migrations under `migrations/<database>/` will be replayed onto the \
freshly-created database. This action cannot be undone."
)?;
write!(handle, "Type `yes` to confirm, anything else to abort: ")?;
handle.flush()?;
let stdin = std::io::stdin();
let mut line = String::new();
stdin.lock().read_line(&mut line)?;
Ok(matches!(
line.trim().to_ascii_lowercase().as_str(),
"y" | "yes"
))
}
pub fn seed_cmd(
database: String,
allow_non_localhost: bool,
workspace: Option<PathBuf>,
) -> ExitCode {
let workspace = resolve_workspace(workspace);
let config = match DjogiConfig::load_from_workspace(&workspace) {
Ok(c) => c,
Err(e) => {
eprintln!("djogi db seed: config load: {e}");
return ExitCode::from(1);
}
};
let runtime = match build_runtime("db seed") {
Ok(r) => r,
Err(code) => return code,
};
let exit = runtime
.block_on(async { run_seed(&workspace, &config, &database, allow_non_localhost).await });
ExitCode::from(exit as u8)
}
async fn run_seed(
workspace: &Path,
config: &DjogiConfig,
database: &str,
allow_non_localhost: bool,
) -> i32 {
let routed_url = match djogi::migrate::derive_per_database_url(&config.database.url, database) {
Some(u) => u,
None => {
let err = SeedError::MalformedApplicationUrl {
application_url: config.database.url.clone(),
};
eprintln!("djogi db seed: {err} (--database `{database}`)");
return 1;
}
};
let pool = match djogi::pg::pool::DjogiPool::connect(&routed_url).await {
Ok(p) => p,
Err(e) => {
eprintln!("djogi db seed: connect: {e}");
return 1;
}
};
if let Err(e) = djogi::pg::preflight::check_postgres_version(&pool).await {
crate::print_support_boundary_error("db seed", &e);
return 2;
}
let mut ctx = djogi::context::DjogiContext::from_pool(pool);
match run_seeds(
&mut ctx,
workspace,
database,
&routed_url,
allow_non_localhost,
)
.await
{
Ok(report) => {
print_seed_report(&report);
0
}
Err(SeedError::LocalhostGate { database_url }) => {
eprintln!(
"djogi db seed: refused — DATABASE_URL `{database_url}` is not \
localhost; pass `--allow-non-localhost` to override"
);
2
}
Err(other) => {
eprintln!("djogi db seed: {other}");
1
}
}
}
fn print_seed_report(report: &SeedReport) {
if report.entries.is_empty() {
println!("db seed: no seeds discovered");
return;
}
let mut applied = 0usize;
let mut skipped = 0usize;
for entry in &report.entries {
let label = match entry.outcome {
SeedOutcome::Applied => {
applied += 1;
"applied"
}
SeedOutcome::SkippedAlreadyApplied => {
skipped += 1;
"skipped (already applied)"
}
};
println!(" {label:>30} {name}", name = entry.seed_name);
}
println!("db seed: {applied} applied, {skipped} skipped");
}
pub fn cleanup_test_dbs_cmd(
dry_run: bool,
yes: bool,
maintenance_database: String,
allow_non_localhost: bool,
workspace: Option<PathBuf>,
) -> ExitCode {
let workspace = resolve_workspace(workspace);
let config = match DjogiConfig::load_from_workspace(&workspace) {
Ok(c) => c,
Err(e) => {
eprintln!("djogi db cleanup-test-dbs: config load: {e}");
return ExitCode::from(1);
}
};
if !allow_non_localhost && !djogi::migrate::is_localhost_connection(&config.database.url) {
eprintln!(
"djogi db cleanup-test-dbs: refused — DATABASE_URL `{}` is not \
localhost; pass `--allow-non-localhost` to override",
config.database.url
);
return ExitCode::from(2);
}
if config.profile == "production" {
eprintln!(
"djogi db cleanup-test-dbs: refused — Djogi.toml::profile = `{}`; \
refusing to run on a production profile",
config.profile
);
return ExitCode::from(2);
}
if !dry_run && !yes {
eprintln!(
"djogi db cleanup-test-dbs: refused — pass `--yes` to confirm, \
or `--dry-run` to list candidates without dropping"
);
return ExitCode::from(2);
}
if !is_valid_pg_identifier(&maintenance_database) {
eprintln!(
"djogi db cleanup-test-dbs: invalid maintenance database name `{maintenance_database}`"
);
return ExitCode::from(1);
}
let admin_url = match djogi::migrate::derive_per_database_url(
&config.database.url,
&maintenance_database,
) {
Some(u) => u,
None => {
eprintln!(
"djogi db cleanup-test-dbs: malformed application URL `{}` — \
cannot derive maintenance connection URL",
config.database.url
);
return ExitCode::from(1);
}
};
let runtime = match build_runtime("db cleanup-test-dbs") {
Ok(r) => r,
Err(code) => return code,
};
let exit = runtime.block_on(async { run_cleanup_test_dbs(&admin_url, dry_run).await });
ExitCode::from(exit as u8)
}
async fn run_cleanup_test_dbs(admin_url: &str, dry_run: bool) -> i32 {
if dry_run {
match djogi::testing::list_orphaned_test_databases(admin_url).await {
Ok(candidates) => {
if candidates.is_empty() {
println!("db cleanup-test-dbs (dry run): no orphaned test databases found");
} else {
println!(
"db cleanup-test-dbs (dry run): {} candidate(s):",
candidates.len()
);
for name in &candidates {
println!(" {name}");
}
}
0
}
Err(e) => {
eprintln!("djogi db cleanup-test-dbs: {e}");
1
}
}
} else {
match djogi::testing::cleanup_orphaned_test_databases(admin_url).await {
Ok(dropped) => {
if dropped.is_empty() {
println!("db cleanup-test-dbs: no orphaned test databases dropped");
} else {
println!(
"db cleanup-test-dbs: dropped {} database(s):",
dropped.len()
);
for name in &dropped {
println!(" {name}");
}
}
0
}
Err(e) => {
eprintln!("djogi db cleanup-test-dbs: {e}");
1
}
}
}
}
fn is_valid_pg_identifier(name: &str) -> bool {
let bytes = name.as_bytes();
if bytes.is_empty() || bytes.len() > 63 {
return false;
}
let first = bytes[0];
if !(first.is_ascii_alphabetic() || first == b'_') {
return false;
}
for &b in &bytes[1..] {
if !(b.is_ascii_alphanumeric() || b == b'_') {
return false;
}
}
true
}
pub fn docs_cmd(
provider: &dyn DescriptorProvider,
output: Option<PathBuf>,
workspace: Option<PathBuf>,
) -> ExitCode {
if provider.models().is_empty() {
crate::print_zero_descriptor_diagnostic("docs");
return ExitCode::from(2);
}
let workspace = resolve_workspace(workspace);
let output = output.unwrap_or_else(|| workspace.join("target").join("djogi-docs"));
let intent = match djogi::intent::load(&workspace) {
Ok(maybe) => maybe,
Err(e) => {
eprintln!("djogi docs: {e}");
return ExitCode::from(1);
}
};
match generate_docs_with_provider(provider, &output, intent.as_ref()) {
Ok(report) => {
println!(
"docs: rendered {n} model page(s) into {path}",
n = report.models_rendered,
path = report.output_root.display(),
);
ExitCode::from(0)
}
Err(e) => {
eprintln!("djogi docs: {e}");
ExitCode::from(1)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::atomic::{AtomicUsize, Ordering};
fn temp_workspace(tag: &str) -> PathBuf {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let p = std::env::temp_dir().join(format!("djogi-cli-db-{tag}-{nanos}-{n}"));
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn reset_cmd_refuses_when_not_confirmed_and_url_remote() {
let work = temp_workspace("reset_remote");
let toml = "[database]\nurl = \"postgres://prod.example.com/main\"\n\
max_connections = 1\ndev_mode = false\n\
[server]\nhost = \"127.0.0.1\"\nport = 1234\n";
fs::write(work.join("Djogi.toml"), toml).unwrap();
let prior = std::env::var("DATABASE_URL").ok();
unsafe { std::env::remove_var("DATABASE_URL") };
let exit = reset_cmd(true, false, "postgres".to_string(), Some(work.clone()));
assert_eq!(exit, ExitCode::from(2), "remote URL must hit refusal exit");
match prior {
Some(v) => unsafe { std::env::set_var("DATABASE_URL", v) },
None => unsafe { std::env::remove_var("DATABASE_URL") },
}
let _ = fs::remove_dir_all(&work);
}
#[test]
fn reset_cmd_refuses_on_production_profile() {
let work = temp_workspace("reset_prod");
let toml = "profile = \"production\"\n\
[database]\nurl = \"postgres://localhost/main\"\n\
max_connections = 1\ndev_mode = false\n\
[server]\nhost = \"127.0.0.1\"\nport = 1234\n";
fs::write(work.join("Djogi.toml"), toml).unwrap();
let prior = std::env::var("DATABASE_URL").ok();
unsafe { std::env::remove_var("DATABASE_URL") };
let exit = reset_cmd(true, false, "postgres".to_string(), Some(work.clone()));
assert_eq!(exit, ExitCode::from(2), "production must refuse");
match prior {
Some(v) => unsafe { std::env::set_var("DATABASE_URL", v) },
None => unsafe { std::env::remove_var("DATABASE_URL") },
}
let _ = fs::remove_dir_all(&work);
}
#[test]
fn cleanup_test_dbs_refuses_non_localhost_without_override() {
let work = temp_workspace("cleanup_remote");
let toml = "[database]\nurl = \"postgres://prod.example.com/main\"\n\
max_connections = 1\ndev_mode = false\n\
[server]\nhost = \"127.0.0.1\"\nport = 1234\n";
fs::write(work.join("Djogi.toml"), toml).unwrap();
let prior = std::env::var("DATABASE_URL").ok();
unsafe { std::env::remove_var("DATABASE_URL") };
let exit = cleanup_test_dbs_cmd(
false,
true,
"postgres".to_string(),
false,
Some(work.clone()),
);
assert_eq!(
exit,
ExitCode::from(2),
"non-localhost without override must refuse"
);
match prior {
Some(v) => unsafe { std::env::set_var("DATABASE_URL", v) },
None => unsafe { std::env::remove_var("DATABASE_URL") },
}
let _ = fs::remove_dir_all(&work);
}
#[test]
fn cleanup_test_dbs_refuses_on_production_profile() {
let work = temp_workspace("cleanup_prod");
let toml = "profile = \"production\"\n\
[database]\nurl = \"postgres://localhost/main\"\n\
max_connections = 1\ndev_mode = false\n\
[server]\nhost = \"127.0.0.1\"\nport = 1234\n";
fs::write(work.join("Djogi.toml"), toml).unwrap();
let prior = std::env::var("DATABASE_URL").ok();
unsafe { std::env::remove_var("DATABASE_URL") };
let exit = cleanup_test_dbs_cmd(
false,
true,
"postgres".to_string(),
false,
Some(work.clone()),
);
assert_eq!(exit, ExitCode::from(2), "production must refuse");
match prior {
Some(v) => unsafe { std::env::set_var("DATABASE_URL", v) },
None => unsafe { std::env::remove_var("DATABASE_URL") },
}
let _ = fs::remove_dir_all(&work);
}
#[test]
fn cleanup_test_dbs_refuses_without_yes_or_dry_run() {
let work = temp_workspace("cleanup_no_yes");
let toml = "[database]\nurl = \"postgres://localhost/main\"\n\
max_connections = 1\ndev_mode = false\n\
[server]\nhost = \"127.0.0.1\"\nport = 1234\n";
fs::write(work.join("Djogi.toml"), toml).unwrap();
let prior = std::env::var("DATABASE_URL").ok();
unsafe { std::env::remove_var("DATABASE_URL") };
let exit = cleanup_test_dbs_cmd(
false,
false,
"postgres".to_string(),
false,
Some(work.clone()),
);
assert_eq!(
exit,
ExitCode::from(2),
"missing --yes without --dry-run must refuse"
);
match prior {
Some(v) => unsafe { std::env::set_var("DATABASE_URL", v) },
None => unsafe { std::env::remove_var("DATABASE_URL") },
}
let _ = fs::remove_dir_all(&work);
}
#[test]
fn cleanup_test_dbs_rejects_invalid_maintenance_database() {
let work = temp_workspace("cleanup_bad_maint");
let toml = "[database]\nurl = \"postgres://localhost/main\"\n\
max_connections = 1\ndev_mode = false\n\
[server]\nhost = \"127.0.0.1\"\nport = 1234\n";
fs::write(work.join("Djogi.toml"), toml).unwrap();
let prior = std::env::var("DATABASE_URL").ok();
unsafe { std::env::remove_var("DATABASE_URL") };
let exit = cleanup_test_dbs_cmd(
false,
true,
"'; DROP DATABASE main; --".to_string(),
false,
Some(work.clone()),
);
assert_eq!(
exit,
ExitCode::from(1),
"invalid maintenance DB name must reject"
);
match prior {
Some(v) => unsafe { std::env::set_var("DATABASE_URL", v) },
None => unsafe { std::env::remove_var("DATABASE_URL") },
}
let _ = fs::remove_dir_all(&work);
}
#[test]
fn is_valid_pg_identifier_byte_grammar() {
assert!(is_valid_pg_identifier("postgres"));
assert!(is_valid_pg_identifier("rdsadmin"));
assert!(is_valid_pg_identifier("_under"));
assert!(is_valid_pg_identifier("a"));
assert!(is_valid_pg_identifier("a_1_b"));
assert!(!is_valid_pg_identifier(""));
assert!(!is_valid_pg_identifier("1starts_with_digit"));
assert!(!is_valid_pg_identifier("has space"));
assert!(!is_valid_pg_identifier("'; DROP TABLE foo; --"));
assert!(!is_valid_pg_identifier(&"a".repeat(64)));
assert!(is_valid_pg_identifier(&"a".repeat(63)));
}
#[test]
fn docs_cmd_against_empty_provider_refuses() {
struct EmptyProvider;
impl djogi::migrate::DescriptorProvider for EmptyProvider {
fn models(&self) -> Vec<&'static djogi::descriptor::ModelDescriptor> {
Vec::new()
}
fn enums(&self) -> Vec<&'static djogi::descriptor::EnumDescriptor> {
Vec::new()
}
fn apps(&self) -> &'static [djogi::apps::AppDescriptor] {
djogi::apps::AppRegistry::all()
}
fn deferrability_specs(&self) -> Vec<&'static djogi::descriptor::DeferrabilitySpec> {
Vec::new()
}
}
let work = temp_workspace("docs_empty_refusal");
let out = work.join("target/djogi-docs");
let exit = docs_cmd(&EmptyProvider, Some(out.clone()), Some(work.clone()));
assert_eq!(exit, ExitCode::from(2));
assert!(
!out.join("README.md").exists(),
"refusal must not render docs"
);
let _ = fs::remove_dir_all(&work);
}
}