use std::path::Path;
use crate::core::Model as _;
use crate::migrate::{DataOp, Migration, MigrationScope, Operation, SchemaChange, SchemaSnapshot};
use super::auth::{Operator, User};
use super::error::TenancyError;
use super::org::Org;
pub const REGISTRY_BOOTSTRAP_NAME: &str = "0001_rustango_registry_initial";
pub const TENANT_BOOTSTRAP_NAME: &str = "0001_rustango_tenant_initial";
const BOOTSTRAP_TIMESTAMP: &str = "2026-04-29T00:00:00Z";
#[must_use]
pub fn registry_bootstrap_migration() -> Migration {
Migration {
name: REGISTRY_BOOTSTRAP_NAME.to_owned(),
created_at: BOOTSTRAP_TIMESTAMP.to_owned(),
prev: None,
atomic: true,
scope: MigrationScope::Registry,
snapshot: full_snapshot(),
forward: vec![
Operation::Schema(SchemaChange::CreateTable("rustango_orgs".into())),
Operation::Schema(SchemaChange::CreateTable(
"rustango_operators".into(),
)),
Operation::Data(unique_constraint("rustango_orgs", "slug")),
Operation::Data(unique_constraint("rustango_operators", "username")),
],
}
}
#[must_use]
pub fn tenant_bootstrap_migration() -> Migration {
Migration {
name: TENANT_BOOTSTRAP_NAME.to_owned(),
created_at: BOOTSTRAP_TIMESTAMP.to_owned(),
prev: None,
atomic: true,
scope: MigrationScope::Tenant,
snapshot: full_snapshot(),
forward: vec![
Operation::Schema(SchemaChange::CreateTable("rustango_users".into())),
Operation::Data(unique_constraint("rustango_users", "username")),
],
}
}
fn full_snapshot() -> SchemaSnapshot {
SchemaSnapshot::from_models(&[Org::SCHEMA, Operator::SCHEMA, User::SCHEMA])
}
fn unique_constraint(table: &str, column: &str) -> DataOp {
DataOp {
sql: format!(
r#"ALTER TABLE "{table}" ADD CONSTRAINT "{table}_{column}_key" UNIQUE ("{column}")"#,
),
reverse_sql: Some(format!(
r#"ALTER TABLE "{table}" DROP CONSTRAINT "{table}_{column}_key""#,
)),
reversible: true,
}
}
#[derive(Debug, Default)]
pub struct InitTenancyReport {
pub written: Vec<String>,
pub skipped: Vec<String>,
}
pub fn init_tenancy(dir: &Path) -> Result<InitTenancyReport, TenancyError> {
if !dir.exists() {
std::fs::create_dir_all(dir)?;
}
let mut report = InitTenancyReport::default();
for mig in [registry_bootstrap_migration(), tenant_bootstrap_migration()] {
let path = dir.join(format!("{}.json", mig.name));
if path.exists() {
report.skipped.push(mig.name);
continue;
}
rustango::migrate::file::write(&path, &mig)?;
report.written.push(mig.name);
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_migration_is_registry_scoped() {
let m = registry_bootstrap_migration();
assert_eq!(m.scope, MigrationScope::Registry);
assert_eq!(m.name, REGISTRY_BOOTSTRAP_NAME);
assert!(m.prev.is_none());
assert_eq!(m.forward.len(), 4);
}
#[test]
fn tenant_migration_is_tenant_scoped() {
let m = tenant_bootstrap_migration();
assert_eq!(m.scope, MigrationScope::Tenant);
assert_eq!(m.name, TENANT_BOOTSTRAP_NAME);
assert!(m.prev.is_none());
assert_eq!(m.forward.len(), 2);
}
#[test]
fn snapshots_contain_all_three_tenancy_tables() {
let s = full_snapshot();
let names: Vec<&str> = s.tables.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"rustango_orgs"));
assert!(names.contains(&"rustango_operators"));
assert!(names.contains(&"rustango_users"));
}
#[test]
fn unique_constraint_emits_alter_table_pair() {
let op = unique_constraint("rustango_orgs", "slug");
assert!(op.sql.contains("ADD CONSTRAINT \"rustango_orgs_slug_key\""));
assert!(op.sql.contains("UNIQUE (\"slug\")"));
assert!(op.reversible);
let reverse = op.reverse_sql.unwrap();
assert!(reverse.contains("DROP CONSTRAINT \"rustango_orgs_slug_key\""));
}
#[test]
fn init_tenancy_writes_then_skips_idempotently() {
let dir = tempdir();
let first = init_tenancy(&dir).unwrap();
assert_eq!(first.written.len(), 2);
assert!(first.skipped.is_empty());
assert!(dir.join(format!("{REGISTRY_BOOTSTRAP_NAME}.json")).exists());
assert!(dir.join(format!("{TENANT_BOOTSTRAP_NAME}.json")).exists());
let second = init_tenancy(&dir).unwrap();
assert!(second.written.is_empty());
assert_eq!(second.skipped.len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
fn tempdir() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::SeqCst);
let pid = std::process::id();
let mut p = std::env::temp_dir();
p.push(format!("rustango_tenancy_bootstrap_test_{pid}_{n}"));
let _ = std::fs::remove_dir_all(&p);
p
}
}