use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::alias_storage::GlobalAliasStorage;
use crate::error::MiniAppError;
use crate::schema::{self, SchemaConfig};
use crate::store::Store;
pub struct TableEntry {
pub store: Arc<Store>,
pub schema: Arc<SchemaConfig>,
pub schema_path: Arc<PathBuf>,
}
pub struct TableRegistry {
entries: HashMap<String, TableEntry>,
default_table: Option<String>,
global_aliases: Option<Arc<GlobalAliasStorage>>,
}
impl TableRegistry {
pub async fn mount_from_dirs(
user_dir: Option<&Path>,
project_dir: Option<&Path>,
) -> Result<Self, MiniAppError> {
let user_dir = user_dir.filter(|p| p.exists());
let project_dir = project_dir.filter(|p| p.exists());
let mut entries: HashMap<String, TableEntry> = HashMap::new();
let global_aliases = if user_dir.is_some() || project_dir.is_some() {
Some(Arc::new(GlobalAliasStorage::open(project_dir, user_dir)?))
} else {
None
};
if let Some(dir) = user_dir {
scan_and_mount(dir, &mut entries).await?;
if let Some(g) = global_aliases.as_ref() {
migrate_per_dir_subset(g, crate::alias_storage::AliasScope::User, dir, &entries)
.await?;
}
}
if let Some(dir) = project_dir {
scan_and_mount(dir, &mut entries).await?;
if let Some(g) = global_aliases.as_ref() {
migrate_per_dir_subset(g, crate::alias_storage::AliasScope::Project, dir, &entries)
.await?;
}
}
Ok(TableRegistry {
entries,
default_table: None,
global_aliases,
})
}
pub async fn mount_legacy(schema_path: &Path, db_path: &Path) -> Result<Self, MiniAppError> {
let schema = schema::load_from_path(schema_path)?;
let table_name = schema.table.clone();
let store = Store::open(db_path, schema.clone()).await?;
let entry = TableEntry {
store: Arc::new(store),
schema: Arc::new(schema),
schema_path: Arc::new(schema_path.to_path_buf()),
};
let mut entries = HashMap::new();
entries.insert(table_name.clone(), entry);
Ok(TableRegistry {
entries,
default_table: Some(table_name),
global_aliases: None,
})
}
pub fn resolve(&self, name: Option<&str>) -> Result<&TableEntry, MiniAppError> {
let key = match name {
Some(n) => n,
None => match &self.default_table {
Some(d) => d.as_str(),
None => return Err(MiniAppError::TableRequired),
},
};
self.entries
.get(key)
.ok_or_else(|| MiniAppError::TableNotFound {
table: key.to_string(),
})
}
pub fn default_table(&self) -> Option<&str> {
self.default_table.as_deref()
}
pub fn table_count(&self) -> usize {
self.entries.len()
}
pub fn table_names(&self) -> impl Iterator<Item = &str> {
self.entries.keys().map(|k| k.as_str())
}
pub fn global_aliases(&self) -> Option<&Arc<GlobalAliasStorage>> {
self.global_aliases.as_ref()
}
pub fn entries(&self) -> &HashMap<String, TableEntry> {
&self.entries
}
pub fn from_entries(
entries: HashMap<String, TableEntry>,
default_table: Option<String>,
) -> Self {
TableRegistry {
entries,
default_table,
global_aliases: None,
}
}
pub fn from_single(
store: Store,
schema: SchemaConfig,
schema_path: PathBuf,
table_name: String,
) -> Self {
let entry = TableEntry {
store: Arc::new(store),
schema: Arc::new(schema),
schema_path: Arc::new(schema_path),
};
let mut entries = HashMap::new();
entries.insert(table_name.clone(), entry);
TableRegistry {
entries,
default_table: Some(table_name),
global_aliases: None,
}
}
pub async fn mount_legacy_into(
mut registry: TableRegistry,
schema_path: &Path,
db_path: &Path,
) -> Result<TableRegistry, MiniAppError> {
let schema = schema::load_from_path(schema_path)?;
let table_name = schema.table.clone();
if registry.entries.contains_key(&table_name) {
tracing::warn!(
table = %table_name,
"legacy table name conflicts with a dir-scanned table; legacy env takes precedence"
);
}
let store = Store::open(db_path, schema.clone()).await?;
let entry = TableEntry {
store: Arc::new(store),
schema: Arc::new(schema),
schema_path: Arc::new(schema_path.to_path_buf()),
};
registry.entries.insert(table_name.clone(), entry);
registry.default_table = Some(table_name);
Ok(registry)
}
}
async fn migrate_per_dir_subset(
storage: &Arc<GlobalAliasStorage>,
scope: crate::alias_storage::AliasScope,
dir: &Path,
entries: &HashMap<String, TableEntry>,
) -> Result<(), MiniAppError> {
let mut subset_names: Vec<String> = Vec::new();
let read_dir = match std::fs::read_dir(dir) {
Ok(rd) => rd,
Err(e) => {
tracing::warn!(
dir = %dir.display(),
error = %e,
"migrate_per_dir_subset could not read dir; skipping"
);
return Ok(());
}
};
for dir_entry in read_dir.flatten() {
let Ok(meta) = dir_entry.metadata() else {
continue;
};
if !meta.is_dir() {
continue;
}
let Some(name) = dir_entry.file_name().to_str().map(str::to_owned) else {
continue;
};
if entries.contains_key(&name) {
subset_names.push(name);
}
}
let per_table: Vec<(String, Arc<std::sync::Mutex<rusqlite::Connection>>)> = subset_names
.iter()
.filter_map(|name| entries.get(name).map(|e| (name.clone(), e.store.conn())))
.collect();
if per_table.is_empty() {
return Ok(());
}
let migrated = storage.migrate_from_per_table(scope, per_table).await?;
if migrated > 0 {
tracing::info!(
migrated_rows = migrated,
scope = ?scope,
"migrated legacy per-table _aliases rows into _global_aliases"
);
}
Ok(())
}
async fn scan_and_mount(
dir: &Path,
entries: &mut HashMap<String, TableEntry>,
) -> Result<(), MiniAppError> {
if !dir.exists() {
tracing::warn!(
dir = %dir.display(),
"directory does not exist, skipping"
);
return Ok(());
}
let read_dir = std::fs::read_dir(dir)?;
for dir_entry_result in read_dir {
let dir_entry = dir_entry_result?;
let metadata = dir_entry.metadata()?;
if !metadata.is_dir() {
continue;
}
let table_dir = dir_entry.path();
let table_name = match table_dir.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => {
tracing::warn!(
path = %table_dir.display(),
"skipping subdirectory with non-UTF-8 name"
);
continue;
}
};
let schema_path = table_dir.join("schema.yaml");
let db_path = table_dir.join(format!("{table_name}.db"));
if !schema_path.exists() {
tracing::warn!(
table = %table_name,
path = %schema_path.display(),
"skipping table: schema.yaml not found"
);
continue;
}
if !db_path.exists() {
tracing::debug!(
table = %table_name,
path = %db_path.display(),
"db file absent, will be created by Store::open"
);
}
let schema = match schema::load_from_path(&schema_path) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
table = %table_name,
error = %e,
"skipping table: failed to parse schema.yaml"
);
continue;
}
};
let store = match Store::open(&db_path, schema.clone()).await {
Ok(s) => s,
Err(e) => {
tracing::warn!(
table = %table_name,
error = %e,
"skipping table: failed to open store"
);
continue;
}
};
tracing::debug!(
table = %table_name,
schema_path = %schema_path.display(),
db_path = %db_path.display(),
"mounted table"
);
entries.insert(
table_name,
TableEntry {
store: Arc::new(store),
schema: Arc::new(schema),
schema_path: Arc::new(schema_path),
},
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn create_table_dir(parent: &TempDir, table_name: &str, fields_yaml: &str) -> PathBuf {
let table_dir = parent.path().join(table_name);
std::fs::create_dir_all(&table_dir).expect("create table dir");
let schema_path = table_dir.join("schema.yaml");
let yaml = format!("table: {table_name}\nfields:\n{fields_yaml}\n");
let mut f = std::fs::File::create(&schema_path).expect("create schema.yaml");
f.write_all(yaml.as_bytes()).expect("write schema.yaml");
table_dir
}
#[tokio::test]
async fn user_scope_only_mounts_two_tables() {
let user_dir = TempDir::new().expect("tempdir");
create_table_dir(
&user_dir,
"notes",
" - name: title\n type: string\n required: true\n",
);
create_table_dir(
&user_dir,
"tasks",
" - name: body\n type: string\n required: false\n",
);
let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
.await
.expect("mount must succeed");
assert_eq!(registry.table_count(), 2);
assert!(registry.resolve(Some("notes")).is_ok());
assert!(registry.resolve(Some("tasks")).is_ok());
assert_eq!(registry.default_table(), None);
}
#[tokio::test]
async fn user_and_project_scopes_merge_with_project_override() {
let user_dir = TempDir::new().expect("tempdir");
let project_dir = TempDir::new().expect("tempdir");
create_table_dir(
&user_dir,
"table_a",
" - name: f\n type: string\n required: false\n",
);
create_table_dir(
&user_dir,
"table_b",
" - name: user_field\n type: string\n required: false\n",
);
create_table_dir(
&project_dir,
"table_b",
" - name: project_field\n type: string\n required: true\n",
);
create_table_dir(
&project_dir,
"table_c",
" - name: g\n type: number\n required: false\n",
);
let registry =
TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
.await
.expect("mount must succeed");
assert_eq!(registry.table_count(), 3);
let entry_a = registry
.resolve(Some("table_a"))
.expect("table_a must exist");
assert_eq!(entry_a.schema.table, "table_a");
let entry_b = registry
.resolve(Some("table_b"))
.expect("table_b must exist");
assert!(
entry_b
.schema
.fields
.iter()
.any(|f| f.name == "project_field"),
"table_b should use Project schema (project_field), not User schema (user_field)"
);
assert!(
!entry_b.schema.fields.iter().any(|f| f.name == "user_field"),
"table_b must not retain User's user_field after Project override"
);
assert!(registry.resolve(Some("table_c")).is_ok());
}
#[tokio::test]
async fn project_override_is_file_level_swap_not_field_merge() {
let user_dir = TempDir::new().expect("tempdir");
let project_dir = TempDir::new().expect("tempdir");
create_table_dir(
&user_dir,
"same_table",
" - name: field_a\n type: string\n required: false\n - name: field_b\n type: string\n required: false\n",
);
create_table_dir(
&project_dir,
"same_table",
" - name: field_c\n type: number\n required: true\n",
);
let registry =
TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
.await
.expect("mount must succeed");
let entry = registry
.resolve(Some("same_table"))
.expect("same_table must exist");
assert_eq!(entry.schema.fields.len(), 1);
assert_eq!(entry.schema.fields[0].name, "field_c");
}
#[tokio::test]
async fn legacy_mode_mounts_one_table_with_default() {
let dir = TempDir::new().expect("tempdir");
let schema_path = dir.path().join("schema.yaml");
let db_path = dir.path().join("legacy.db");
let yaml =
"table: legacy_table\nfields:\n - name: title\n type: string\n required: true\n";
std::fs::write(&schema_path, yaml).expect("write schema.yaml");
let registry = TableRegistry::mount_legacy(&schema_path, &db_path)
.await
.expect("mount_legacy must succeed");
assert_eq!(registry.table_count(), 1);
assert_eq!(registry.default_table(), Some("legacy_table"));
let entry = registry
.resolve(None)
.expect("default resolve must succeed");
assert_eq!(entry.schema.table, "legacy_table");
let entry2 = registry
.resolve(Some("legacy_table"))
.expect("explicit resolve must succeed");
assert_eq!(entry2.schema.table, "legacy_table");
}
#[tokio::test]
async fn empty_dirs_mount_zero_tables() {
let user_dir = TempDir::new().expect("tempdir");
let project_dir = TempDir::new().expect("tempdir");
let registry =
TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
.await
.expect("mount must not fail for empty dirs");
assert_eq!(registry.table_count(), 0);
}
#[tokio::test]
async fn both_dirs_none_mounts_zero_tables() {
let registry = TableRegistry::mount_from_dirs(None, None)
.await
.expect("mount must not fail when both dirs are None");
assert_eq!(registry.table_count(), 0);
}
#[tokio::test]
async fn nonexistent_dir_is_skipped_not_fatal() {
let user_dir = TempDir::new().expect("tempdir");
create_table_dir(
&user_dir,
"table_a",
" - name: f\n type: string\n required: false\n",
);
let nonexistent = PathBuf::from("/nonexistent/path/that/does/not/exist");
let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(&nonexistent))
.await
.expect("mount must succeed even when project_dir does not exist");
assert_eq!(registry.table_count(), 1);
assert!(registry.resolve(Some("table_a")).is_ok());
}
#[tokio::test]
async fn subdir_without_schema_yaml_is_skipped() {
let user_dir = TempDir::new().expect("tempdir");
std::fs::create_dir(user_dir.path().join("no_schema")).expect("create dir");
create_table_dir(
&user_dir,
"valid_table",
" - name: f\n type: string\n required: false\n",
);
let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
.await
.expect("mount must succeed");
assert_eq!(registry.table_count(), 1);
assert!(registry.resolve(Some("valid_table")).is_ok());
}
#[tokio::test]
async fn resolve_none_without_default_returns_table_required() {
let user_dir = TempDir::new().expect("tempdir");
create_table_dir(
&user_dir,
"table_a",
" - name: f\n type: string\n required: false\n",
);
create_table_dir(
&user_dir,
"table_b",
" - name: g\n type: string\n required: false\n",
);
let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
.await
.expect("mount must succeed");
let result = registry.resolve(None);
assert!(
result.is_err(),
"resolve(None) must fail with no default table"
);
if let Err(err) = result {
assert!(
matches!(err, MiniAppError::TableRequired),
"expected TableRequired, got: {err:?}"
);
}
}
#[tokio::test]
async fn resolve_unknown_table_returns_table_not_found() {
let user_dir = TempDir::new().expect("tempdir");
create_table_dir(
&user_dir,
"table_a",
" - name: f\n type: string\n required: false\n",
);
let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
.await
.expect("mount must succeed");
let result = registry.resolve(Some("nonexistent"));
assert!(result.is_err(), "resolve(nonexistent) must fail");
if let Err(err) = result {
match err {
MiniAppError::TableNotFound { table } => {
assert_eq!(table, "nonexistent");
}
other => panic!("expected TableNotFound, got: {other:?}"),
}
}
}
#[tokio::test]
async fn mount_from_dirs_attaches_global_alias_storage() {
let user_dir = TempDir::new().expect("tempdir");
create_table_dir(
&user_dir,
"rows",
" - name: f\n type: string\n required: false\n",
);
let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
.await
.expect("mount must succeed");
assert!(
registry.global_aliases().is_some(),
"mount_from_dirs with user_dir must attach GlobalAliasStorage"
);
}
#[tokio::test]
async fn mount_from_dirs_without_any_dir_has_no_global_alias_storage() {
let registry = TableRegistry::mount_from_dirs(None, None)
.await
.expect("mount must succeed even with no dirs");
assert!(
registry.global_aliases().is_none(),
"mount_from_dirs(None, None) must not attach GlobalAliasStorage"
);
}
#[tokio::test]
async fn mount_legacy_has_no_global_alias_storage() {
let dir = TempDir::new().expect("tempdir");
let schema_path = dir.path().join("schema.yaml");
std::fs::write(
&schema_path,
"table: notes\nfields:\n - name: title\n type: string\n required: true\n",
)
.expect("write schema");
let db_path = dir.path().join("notes.db");
let registry = TableRegistry::mount_legacy(&schema_path, &db_path)
.await
.expect("mount_legacy must succeed");
assert!(
registry.global_aliases().is_none(),
"mount_legacy must not attach GlobalAliasStorage"
);
}
#[tokio::test]
async fn mount_from_dirs_routes_per_table_aliases_to_origin_scope() {
use crate::alias_storage::{AliasScope, LEGACY_PER_TABLE_ALIASES_SQL};
let user_dir = TempDir::new().expect("tempdir");
let project_dir = TempDir::new().expect("tempdir");
let user_table = create_table_dir(
&user_dir,
"user_only",
" - name: f\n type: string\n required: false\n",
);
let user_db = user_table.join("user_only.db");
let conn = rusqlite::Connection::open(&user_db).expect("open user db");
conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
conn.execute(
"INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
"from_user",
"{}",
Some(7i64),
Some("user-scope alias".to_string()),
Option::<String>::None
],
)
.unwrap();
drop(conn);
let proj_table = create_table_dir(
&project_dir,
"proj_only",
" - name: f\n type: string\n required: false\n",
);
let proj_db = proj_table.join("proj_only.db");
let conn = rusqlite::Connection::open(&proj_db).expect("open proj db");
conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
conn.execute(
"INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
"from_project",
"{}",
Some(11i64),
Some("project-scope alias".to_string()),
Option::<String>::None
],
)
.unwrap();
drop(conn);
let registry =
TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
.await
.expect("mount must succeed");
let global = registry
.global_aliases()
.expect("global storage must be attached");
let user_alias = global
.alias_get_scope(AliasScope::User, "from_user")
.await
.expect("user alias_get_scope ok")
.expect("user alias must be present in User scope");
assert_eq!(user_alias.description.as_deref(), Some("user-scope alias"));
let user_in_project = global
.alias_get_scope(AliasScope::Project, "from_user")
.await
.expect("project alias_get_scope ok");
assert!(
user_in_project.is_none(),
"user-origin alias must NOT leak into Project scope (silent inversion fix)"
);
let proj_alias = global
.alias_get_scope(AliasScope::Project, "from_project")
.await
.expect("project alias_get_scope ok")
.expect("project alias must be present in Project scope");
assert_eq!(
proj_alias.description.as_deref(),
Some("project-scope alias")
);
let proj_in_user = global
.alias_get_scope(AliasScope::User, "from_project")
.await
.expect("user alias_get_scope ok");
assert!(
proj_in_user.is_none(),
"project-origin alias must NOT leak into User scope"
);
}
#[tokio::test]
async fn mount_from_dirs_preserves_user_alias_when_project_overrides_table() {
use crate::alias_storage::{AliasScope, LEGACY_PER_TABLE_ALIASES_SQL};
let user_dir = TempDir::new().expect("tempdir");
let project_dir = TempDir::new().expect("tempdir");
let user_table = create_table_dir(
&user_dir,
"foo",
" - name: f\n type: string\n required: false\n",
);
let user_db = user_table.join("foo.db");
let conn = rusqlite::Connection::open(&user_db).expect("open user foo db");
conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
conn.execute(
"INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
"shared",
"{}",
Option::<i64>::None,
Some("user".to_string()),
Option::<String>::None
],
)
.unwrap();
drop(conn);
let proj_table = create_table_dir(
&project_dir,
"foo",
" - name: f\n type: string\n required: false\n",
);
let proj_db = proj_table.join("foo.db");
let conn = rusqlite::Connection::open(&proj_db).expect("open project foo db");
conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
conn.execute(
"INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
"shared",
"{}",
Option::<i64>::None,
Some("project".to_string()),
Option::<String>::None
],
)
.unwrap();
drop(conn);
let registry =
TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
.await
.expect("mount must succeed");
let global = registry
.global_aliases()
.expect("global storage must be attached");
let user_row = global
.alias_get_scope(AliasScope::User, "shared")
.await
.unwrap()
.expect("user shared alias preserved");
assert_eq!(user_row.description.as_deref(), Some("user"));
let project_row = global
.alias_get_scope(AliasScope::Project, "shared")
.await
.unwrap()
.expect("project shared alias present");
assert_eq!(project_row.description.as_deref(), Some("project"));
let merged = global.alias_get("shared").await.unwrap();
assert_eq!(merged.description.as_deref(), Some("project"));
assert_eq!(merged.scope, Some(AliasScope::Project));
}
#[tokio::test]
async fn mount_from_dirs_auto_migrates_per_table_aliases() {
use crate::alias_storage::LEGACY_PER_TABLE_ALIASES_SQL;
let project_dir = TempDir::new().expect("tempdir");
let table_dir = create_table_dir(
&project_dir,
"rows",
" - name: f\n type: string\n required: false\n",
);
let db_path = table_dir.join("rows.db");
let conn = rusqlite::Connection::open(&db_path).expect("open per-table db");
conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL)
.expect("create _aliases");
conn.execute(
"INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
"legacy_alias",
"{}",
Some(10i64),
Some("preserved".to_string()),
Option::<String>::None
],
)
.expect("seed legacy alias");
drop(conn);
let registry = TableRegistry::mount_from_dirs(None, Some(project_dir.path()))
.await
.expect("mount must succeed");
let global = registry
.global_aliases()
.expect("global storage must be attached");
let all = global.alias_list().await.expect("alias_list");
assert_eq!(all.len(), 1, "exactly one alias should be migrated");
let rec = &all[0];
assert_eq!(rec.name, "legacy_alias");
assert!(
matches!(&rec.sources, crate::aggregator::SourceSpec::Single(t) if t == "rows"),
"migrated row must have sources=Single(rows), got {:?}",
rec.sources
);
assert!(rec.aggregator.is_none());
assert_eq!(rec.default_limit, Some(10));
assert_eq!(rec.description.as_deref(), Some("preserved"));
}
}