use std::path::Path;
use crate::core::Model as _;
use crate::migrate::{Migration, MigrationScope, Operation, SchemaChange, SchemaSnapshot};
use super::auth::{Operator, User};
use super::auth_backends::ApiKey;
use super::error::TenancyError;
use super::org::Org;
use super::permissions::{Role, RolePermission, UserPermission, UserRole};
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())),
],
}
}
#[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())),
],
}
}
fn full_snapshot() -> SchemaSnapshot {
SchemaSnapshot::from_models(&[
Org::SCHEMA,
Operator::SCHEMA,
User::SCHEMA,
Role::SCHEMA,
RolePermission::SCHEMA,
UserRole::SCHEMA,
UserPermission::SCHEMA,
ApiKey::SCHEMA,
])
}
#[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(), 2);
}
#[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(), 1);
}
#[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 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
}
}