use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
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>,
}
impl TableRegistry {
pub async fn mount_from_dirs(
user_dir: Option<&Path>,
project_dir: Option<&Path>,
) -> Result<Self, MiniAppError> {
let mut entries: HashMap<String, TableEntry> = HashMap::new();
if let Some(dir) = user_dir {
scan_and_mount(dir, &mut entries).await?;
}
if let Some(dir) = project_dir {
scan_and_mount(dir, &mut entries).await?;
}
Ok(TableRegistry {
entries,
default_table: None,
})
}
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),
})
}
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 entries(&self) -> &HashMap<String, TableEntry> {
&self.entries
}
pub fn from_entries(
entries: HashMap<String, TableEntry>,
default_table: Option<String>,
) -> Self {
TableRegistry {
entries,
default_table,
}
}
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),
}
}
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 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:?}"),
}
}
}
}