use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use super::naming::{MIGRATION_DOWN_SUFFIX, MIGRATION_FILE_EXT};
use super::projection::BucketKey;
pub const MIGRATIONS_DIR: &str = "migrations";
pub const PENDING_DIR: &str = "djogi_pending";
pub const GLOBAL_BUCKET_DIRNAME: &str = "_global_";
pub const SNAPSHOT_FILENAME: &str = "schema_snapshot.json";
pub const MODELS_INVENTORY_FILENAME: &str = "djogi_models.json";
pub fn app_dirname(app_label: &str) -> &str {
if app_label.is_empty() {
GLOBAL_BUCKET_DIRNAME
} else {
app_label
}
}
pub fn app_label_from_dirname(dirname: &str) -> &str {
if dirname == GLOBAL_BUCKET_DIRNAME {
""
} else {
dirname
}
}
pub fn migrations_root(workspace_root: &Path) -> PathBuf {
workspace_root.join(MIGRATIONS_DIR)
}
pub fn database_dir(workspace_root: &Path, database: &str) -> PathBuf {
migrations_root(workspace_root).join(database)
}
pub fn bucket_dir(workspace_root: &Path, bucket: &BucketKey) -> PathBuf {
database_dir(workspace_root, &bucket.database).join(app_dirname(&bucket.app))
}
pub fn snapshot_path(workspace_root: &Path, bucket: &BucketKey) -> PathBuf {
bucket_dir(workspace_root, bucket).join(SNAPSHOT_FILENAME)
}
pub fn pending_root(workspace_root: &Path) -> PathBuf {
workspace_root.join("target").join(PENDING_DIR)
}
pub fn pending_database_dir(workspace_root: &Path, database: &str) -> PathBuf {
pending_root(workspace_root).join(database)
}
pub fn phase_zero_pending_dir(workspace_root: &Path, database: &str) -> PathBuf {
pending_database_dir(workspace_root, database).join(".phase_zero")
}
pub fn pending_json_path(workspace_root: &Path, bucket: &BucketKey) -> PathBuf {
pending_database_dir(workspace_root, &bucket.database)
.join(super::naming::pending_json_filename(&bucket.app))
}
pub fn phase_zero_pending_json_path(
workspace_root: &Path,
database: &str,
version: &str,
) -> PathBuf {
phase_zero_pending_dir(workspace_root, database)
.join(super::naming::phase_zero_pending_json_filename(version))
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct FilesystemBucket {
pub database: String,
pub app: String,
}
pub fn scan_filesystem(workspace_root: &Path) -> Result<BTreeSet<FilesystemBucket>, io::Error> {
scan_filesystem_filtered(workspace_root, None)
}
fn scan_filesystem_filtered(
workspace_root: &Path,
database_filter: Option<&str>,
) -> Result<BTreeSet<FilesystemBucket>, io::Error> {
let mut out = BTreeSet::new();
let migrations = migrations_root(workspace_root);
let entries = match fs::read_dir(&migrations) {
Ok(e) => e,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(out),
Err(err) => return Err(err),
};
for entry in entries {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let Some(database) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
if !is_acceptable_dir_name(database.as_bytes()) {
continue;
}
if let Some(want) = database_filter
&& database != want
{
continue;
}
let database_path = entry.path();
let app_entries = match fs::read_dir(&database_path) {
Ok(e) => e,
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(err),
};
for app_entry in app_entries {
let app_entry = app_entry?;
if !app_entry.file_type()?.is_dir() {
continue;
}
let Some(app_dir_name) = app_entry.file_name().to_str().map(str::to_string) else {
continue;
};
if !is_acceptable_dir_name(app_dir_name.as_bytes()) {
continue;
}
let label = app_label_from_dirname(&app_dir_name).to_string();
out.insert(FilesystemBucket {
database: database.clone(),
app: label,
});
}
}
Ok(out)
}
pub fn scan_filesystem_with_files(
workspace_root: &Path,
database_filter: Option<&str>,
) -> Result<BTreeMap<BucketKey, BTreeMap<String, PathBuf>>, io::Error> {
let mut out: BTreeMap<BucketKey, BTreeMap<String, PathBuf>> = BTreeMap::new();
let buckets = scan_filesystem_filtered(workspace_root, database_filter)?;
for fb in buckets {
let bucket = BucketKey {
database: fb.database,
app: fb.app,
};
let dir = bucket_dir(workspace_root, &bucket);
let entries = match fs::read_dir(&dir) {
Ok(e) => e,
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(err),
};
let mut version_candidates: HashMap<String, Vec<PathBuf>> = HashMap::new();
for entry in entries {
let entry = entry?;
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
if !name.starts_with('V') {
continue;
}
if name.ends_with(MIGRATION_DOWN_SUFFIX) {
continue;
}
if name.ends_with(".down.sql") {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("legacy schema migration down file rejected: {name}"),
));
}
let ext = if name.ends_with(MIGRATION_FILE_EXT) {
MIGRATION_FILE_EXT
} else if name.ends_with(".sql") {
".sql"
} else {
continue; };
let stem = &name[..name.len() - ext.len()];
let Some(version) = recover_version_from_stem(stem) else {
continue;
};
version_candidates
.entry(version)
.or_default()
.push(entry.path());
}
for (version, paths) in &version_candidates {
if paths.len() > 1 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("duplicate schema migration version {version}: {paths:?}"),
));
}
}
for (version, paths) in &version_candidates {
let path = &paths[0]; if path.extension().and_then(|e| e.to_str()) == Some("sql") {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"legacy schema migration file rejected for {version}: {:?}; \
schema migrations must use .sdjql",
path
),
));
}
out.entry(bucket.clone())
.or_default()
.insert(version.clone(), path.clone());
}
}
Ok(out)
}
pub(super) fn recover_version_from_stem(stem: &str) -> Option<String> {
let bytes = stem.as_bytes();
if bytes.is_empty() || bytes[0] != b'V' {
return None;
}
let mut i = 1usize;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i == 1 {
return None;
}
if i == bytes.len() {
return Some(stem.to_string());
}
if i + 1 < bytes.len() && bytes[i] == b'_' && bytes[i + 1] == b'_' {
return Some(stem.to_string());
}
None
}
fn is_acceptable_dir_name(bytes: &[u8]) -> bool {
if bytes.is_empty() || bytes.len() > 63 {
return false;
}
if bytes[0] == b'.' {
return false;
}
let first = bytes[0];
if first != b'_' && !first.is_ascii_alphabetic() {
return false;
}
for &b in &bytes[1..] {
if b != b'_' && !b.is_ascii_alphanumeric() {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn temp_root(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();
std::env::temp_dir().join(format!("djogi-target-{tag}-{nanos}-{n}"))
}
#[test]
fn app_dirname_maps_global_label() {
assert_eq!(app_dirname(""), GLOBAL_BUCKET_DIRNAME);
assert_eq!(app_dirname("billing"), "billing");
}
#[test]
fn app_label_from_dirname_maps_global() {
assert_eq!(app_label_from_dirname(GLOBAL_BUCKET_DIRNAME), "");
assert_eq!(app_label_from_dirname("billing"), "billing");
}
#[test]
fn snapshot_path_for_global_bucket() {
let root = Path::new("/work");
let bucket = BucketKey {
database: "main".into(),
app: "".into(),
};
assert_eq!(
snapshot_path(root, &bucket),
Path::new("/work/migrations/main/_global_/schema_snapshot.json")
);
}
#[test]
fn snapshot_path_for_named_app() {
let root = Path::new("/work");
let bucket = BucketKey {
database: "crud_log".into(),
app: "audit".into(),
};
assert_eq!(
snapshot_path(root, &bucket),
Path::new("/work/migrations/crud_log/audit/schema_snapshot.json")
);
}
#[test]
fn pending_json_path_for_global_bucket() {
let root = Path::new("/work");
let bucket = BucketKey {
database: "main".into(),
app: "".into(),
};
assert_eq!(
pending_json_path(root, &bucket),
Path::new("/work/target/djogi_pending/main/_global_.json")
);
}
#[test]
fn phase_zero_pending_json_path_uses_hidden_namespace() {
let root = Path::new("/work");
assert_eq!(
phase_zero_pending_json_path(root, "main", "V00000000000000__phase_zero_bootstrap"),
Path::new(
"/work/target/djogi_pending/main/.phase_zero/V00000000000000__phase_zero_bootstrap.json"
)
);
}
#[test]
fn scan_filesystem_handles_missing_root() {
let root = temp_root("missing");
let buckets = scan_filesystem(&root).expect("ok");
assert!(buckets.is_empty());
}
#[test]
fn scan_filesystem_finds_two_buckets() {
let root = temp_root("two");
fs::create_dir_all(root.join("migrations/main/billing")).unwrap();
fs::create_dir_all(root.join("migrations/main/_global_")).unwrap();
fs::create_dir_all(root.join("migrations/crud_log/audit")).unwrap();
let buckets = scan_filesystem(&root).expect("ok");
let expect: BTreeSet<FilesystemBucket> = [
FilesystemBucket {
database: "crud_log".into(),
app: "audit".into(),
},
FilesystemBucket {
database: "main".into(),
app: "".into(),
},
FilesystemBucket {
database: "main".into(),
app: "billing".into(),
},
]
.into_iter()
.collect();
assert_eq!(buckets, expect);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn scan_filesystem_skips_files_and_hidden_dirs() {
let root = temp_root("hidden");
fs::create_dir_all(root.join("migrations/main/billing")).unwrap();
fs::create_dir_all(root.join("migrations/.git/objects")).unwrap();
fs::write(root.join("migrations/README.md"), "noop").unwrap();
fs::write(root.join("migrations/main/billing/V1__init.sql"), "").unwrap();
let buckets = scan_filesystem(&root).expect("ok");
let expect: BTreeSet<FilesystemBucket> = [FilesystemBucket {
database: "main".into(),
app: "billing".into(),
}]
.into_iter()
.collect();
assert_eq!(buckets, expect);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn is_acceptable_dir_name_rules() {
assert!(is_acceptable_dir_name(b"main"));
assert!(is_acceptable_dir_name(b"_global_"));
assert!(is_acceptable_dir_name(b"crud_log"));
assert!(is_acceptable_dir_name(b"app1"));
assert!(!is_acceptable_dir_name(b""));
assert!(!is_acceptable_dir_name(b".git"));
assert!(!is_acceptable_dir_name(b"1leading_digit"));
assert!(!is_acceptable_dir_name(b"has-dash"));
assert!(!is_acceptable_dir_name(b"has space"));
let ok63: Vec<u8> = std::iter::repeat_n(b'a', 63).collect();
assert!(is_acceptable_dir_name(&ok63));
let bad64: Vec<u8> = std::iter::repeat_n(b'a', 64).collect();
assert!(!is_acceptable_dir_name(&bad64));
}
#[test]
fn recover_version_from_canonical_stem() {
let v = recover_version_from_stem("V20260425010203__add_users").expect("canonical form");
assert_eq!(v, "V20260425010203__add_users");
}
#[test]
fn recover_version_from_bare_prefix() {
let v = recover_version_from_stem("V20260425010203").expect("bare prefix");
assert_eq!(v, "V20260425010203");
}
#[test]
fn recover_version_rejects_no_v_prefix() {
assert!(recover_version_from_stem("20260425010203__init").is_none());
}
#[test]
fn recover_version_rejects_v_alone() {
assert!(recover_version_from_stem("V").is_none());
}
#[test]
fn recover_version_rejects_v_then_letters() {
assert!(recover_version_from_stem("Vinit").is_none());
}
#[test]
fn scan_filesystem_accepts_sdjql_extension() {
let root = temp_root("sdjql-accept");
let bucket = root.join("migrations/main/myapp");
fs::create_dir_all(&bucket).unwrap();
fs::write(bucket.join("V20260425010203__test.sdjql"), "SELECT 1;").unwrap();
fs::write(bucket.join("V20260425010203__test.down.sdjql"), "SELECT 1;").unwrap();
let result = scan_filesystem_with_files(&root, None).unwrap();
let bk = BucketKey {
database: "main".to_string(),
app: "myapp".to_string(),
};
assert!(result.contains_key(&bk), "scanner must find .sdjql files");
assert_eq!(result[&bk].len(), 1); }
#[test]
fn scan_filesystem_rejects_legacy_sql_schema_migration_files() {
let root = temp_root("legacy-sql-reject");
let bucket = root.join("migrations/main/myapp");
fs::create_dir_all(&bucket).unwrap();
fs::write(bucket.join("V20260425010203__legacy.sql"), "SELECT 1;").unwrap();
let result = scan_filesystem_with_files(&root, None);
assert!(
result.is_err(),
"scanner must reject .sql schema migration files"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("legacy") || err.contains("V20260425010203__legacy.sql"),
"error must mention legacy format or filename: {err}"
);
}
#[test]
fn scan_filesystem_detects_duplicate_same_version_artifacts() {
let root = temp_root("duplicate-detect");
let bucket = root.join("migrations/main/myapp");
fs::create_dir_all(&bucket).unwrap();
fs::write(bucket.join("V20260425010203__test.sql"), "SELECT 1;").unwrap();
fs::write(bucket.join("V20260425010203__test.sdjql"), "SELECT 1;").unwrap();
let result = scan_filesystem_with_files(&root, None);
assert!(
result.is_err(),
"scanner must detect duplicate recovered-key artifacts"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("V20260425010203__test") && err.contains("duplicate"),
"error must mention version and duplicates: {err}"
);
}
#[test]
fn scan_filesystem_skips_down_side_for_sdjql() {
let root = temp_root("skip-down-sdjql");
let bucket = root.join("migrations/main/myapp");
fs::create_dir_all(&bucket).unwrap();
fs::write(bucket.join("V20260425010203__a.sdjql"), "SELECT 1;").unwrap();
fs::write(bucket.join("V20260425010203__a.down.sdjql"), "SELECT 1;").unwrap();
fs::write(bucket.join("V20260425010204__b.sdjql"), "SELECT 1;").unwrap();
fs::write(bucket.join("V20260425010204__b.down.sdjql"), "SELECT 1;").unwrap();
let result = scan_filesystem_with_files(&root, None).unwrap();
let bk = BucketKey {
database: "main".to_string(),
app: "myapp".to_string(),
};
assert_eq!(result[&bk].len(), 2); }
}