Skip to main content

mini_app_core/
registry.rs

1/// Multi-table registry for mini-app-mcp.
2///
3/// [`TableRegistry`] holds all mounted tables discovered from the User-scope
4/// and Project-scope directories, as well as any legacy single-table
5/// configuration provided via `MINI_APP_SCHEMA` / `MINI_APP_DB` environment
6/// variables.
7///
8/// # Crux constraints enforced here
9///
10/// - **crux #1 (User→Project schema chain merge)**: [`TableRegistry::mount_from_dirs`]
11///   always accepts both `user_dir` and `project_dir` paths. It first scans
12///   `user_dir` (User scope), then scans `project_dir` (Project scope), with
13///   the Project scan overriding same-named tables at the file level. Neither
14///   directory can be silently skipped in the API; both are explicit parameters.
15///
16/// - **crux #2 (table argument API + single-table backward compat)**:
17///   [`TableRegistry::mount_legacy`] registers a single table and sets
18///   `default_table`. When a `default_table` is set, callers may omit the
19///   `table` argument and [`TableRegistry::resolve`] returns the default store.
20///
21/// # Thread safety
22///
23/// Each [`TableRegistry`] snapshot is immutable. The active registry held by
24/// the server is replaced atomically via `ArcSwap` on `reload` tool
25/// invocation; in-flight requests continue against their captured snapshot.
26use std::collections::HashMap;
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29
30use crate::alias_storage::GlobalAliasStorage;
31use crate::error::MiniAppError;
32use crate::schema::{self, SchemaConfig};
33use crate::store::Store;
34
35// =============================================================================
36// TableRegistry
37// =============================================================================
38
39/// Resolved entry for a single mounted table.
40///
41/// Holds the `Arc`-wrapped store, schema, and schema file path so consumers
42/// can access all three without a separate map lookup per field.
43pub struct TableEntry {
44    /// The running store for this table.
45    pub store: Arc<Store>,
46    /// The parsed schema configuration.
47    pub schema: Arc<SchemaConfig>,
48    /// Filesystem path to `schema.yaml` (used for lazy schema resource reads).
49    pub schema_path: Arc<PathBuf>,
50}
51
52/// Registry of all mounted tables for the current server instance.
53///
54/// Build with [`TableRegistry::mount_from_dirs`] (multi-table) or
55/// [`TableRegistry::mount_legacy`] (single-table legacy mode). After
56/// construction the registry is immutable.
57pub struct TableRegistry {
58    /// All mounted tables keyed by table name.
59    entries: HashMap<String, TableEntry>,
60    /// The default table name, set only in legacy single-table mode.
61    default_table: Option<String>,
62    /// Global alias storage (Phase 2). `None` in legacy single-table mode
63    /// where no `user_dir` / `project_dir` is available. Holds the
64    /// Project + User scope `_global.db` handles, lookup precedence
65    /// Project → User.
66    global_aliases: Option<Arc<GlobalAliasStorage>>,
67}
68
69impl TableRegistry {
70    /// Mount tables discovered from the User-scope and Project-scope directories.
71    ///
72    /// This is the **crux #1** entry point. It performs a two-phase scan:
73    /// 1. Scan `user_dir` (base layer): every subdirectory `<table>/` that
74    ///    contains both `schema.yaml` and `<table>.db` is mounted.
75    /// 2. Scan `project_dir` (override layer): same discovery, but any table
76    ///    name already present from the User scan is **replaced** (file-level
77    ///    swap, not field-level merge).
78    ///
79    /// Either argument may be `None` (e.g. if the directory does not exist or
80    /// was not configured). A non-existent directory is skipped with a
81    /// `tracing::warn!`; it is not a fatal error.
82    ///
83    /// # Arguments
84    ///
85    /// - `user_dir`: path to the User-scope directory (e.g. `~/.mini-app/`).
86    ///   Subdirectories represent table names.
87    /// - `project_dir`: path to the Project-scope directory (e.g.
88    ///   `./.mini-app/`). Overrides same-named User tables.
89    ///
90    /// # Returns
91    ///
92    /// A [`TableRegistry`] with all discovered tables mounted and
93    /// `default_table = None` (no default in multi-table mode).
94    ///
95    /// # Errors
96    ///
97    /// Returns [`MiniAppError::Io`] if a directory can be opened but
98    /// `read_dir` or file reads fail. Missing directories are skipped, not
99    /// treated as errors.
100    pub async fn mount_from_dirs(
101        user_dir: Option<&Path>,
102        project_dir: Option<&Path>,
103    ) -> Result<Self, MiniAppError> {
104        // Honour the "missing dir = skip with warn" policy uniformly
105        // (scan_and_mount applies the same filter internally; the
106        // global storage open path also needs the existence guard so
107        // create_dir_all does not fail on a read-only / inaccessible
108        // parent).
109        let user_dir = user_dir.filter(|p| p.exists());
110        let project_dir = project_dir.filter(|p| p.exists());
111
112        let mut entries: HashMap<String, TableEntry> = HashMap::new();
113
114        // Open the global alias storage upfront so we can route the
115        // per-scope migrations through their respective destinations
116        // (User scope for user_dir-origin tables, Project scope for
117        // project_dir-origin tables — preserves the "user-scope alias
118        // follows the user across projects" intent).
119        let global_aliases = if user_dir.is_some() || project_dir.is_some() {
120            Some(Arc::new(GlobalAliasStorage::open(project_dir, user_dir)?))
121        } else {
122            None
123        };
124
125        // Phase 1: User scope scan + per-scope migration. The user
126        // scan runs BEFORE the project scan so user-origin entries
127        // are still present in `entries` at this point — that lets us
128        // pull each user-origin store's connection even for tables
129        // that the project scan will later override (override would
130        // otherwise replace the entry and we would lose the
131        // user-origin Store handle).
132        if let Some(dir) = user_dir {
133            scan_and_mount(dir, &mut entries).await?;
134            if let Some(g) = global_aliases.as_ref() {
135                migrate_per_dir_subset(g, crate::alias_storage::AliasScope::User, dir, &entries)
136                    .await?;
137            }
138        }
139
140        // Phase 2: Project scope scan (override layer) + Project-scope
141        // migration. After this point any user-scope alias whose table
142        // was overridden by a project entry is already safely written
143        // to the User scope above; the project-side `_aliases` rows
144        // land in the Project scope here.
145        if let Some(dir) = project_dir {
146            scan_and_mount(dir, &mut entries).await?;
147            if let Some(g) = global_aliases.as_ref() {
148                migrate_per_dir_subset(g, crate::alias_storage::AliasScope::Project, dir, &entries)
149                    .await?;
150            }
151        }
152
153        Ok(TableRegistry {
154            entries,
155            default_table: None,
156            global_aliases,
157        })
158    }
159
160    /// Mount a single legacy table from explicit `schema_path` and `db_path`.
161    ///
162    /// This is the **crux #2** entry point. It registers the table described by
163    /// `schema_path` and sets `default_table` to that table's name so callers
164    /// can omit the `table` argument when using [`TableRegistry::resolve`].
165    ///
166    /// # Arguments
167    ///
168    /// - `schema_path`: path to the `schema.yaml` file.
169    /// - `db_path`: path to the SQLite database file.
170    ///
171    /// # Returns
172    ///
173    /// A [`TableRegistry`] with a single table mounted and
174    /// `default_table = Some(<table_name>)`.
175    ///
176    /// # Errors
177    ///
178    /// - [`MiniAppError::Io`] — if `schema_path` cannot be read.
179    /// - [`MiniAppError::Schema`] — if `schema.yaml` is malformed.
180    /// - [`MiniAppError::Storage`] — if the SQLite database cannot be opened.
181    pub async fn mount_legacy(schema_path: &Path, db_path: &Path) -> Result<Self, MiniAppError> {
182        let schema = schema::load_from_path(schema_path)?;
183        let table_name = schema.table.clone();
184        let store = Store::open(db_path, schema.clone()).await?;
185
186        let entry = TableEntry {
187            store: Arc::new(store),
188            schema: Arc::new(schema),
189            schema_path: Arc::new(schema_path.to_path_buf()),
190        };
191
192        let mut entries = HashMap::new();
193        entries.insert(table_name.clone(), entry);
194
195        Ok(TableRegistry {
196            entries,
197            default_table: Some(table_name),
198            global_aliases: None,
199        })
200    }
201
202    /// Resolve a table by name, falling back to `default_table` when `name` is
203    /// `None`.
204    ///
205    /// This is the **crux #2** runtime entry point. When a single-table legacy
206    /// env is set and `default_table` is `Some`, `name = None` is allowed and
207    /// returns the default entry. In multi-table mode (`default_table = None`)
208    /// `name` must be `Some`.
209    ///
210    /// # Arguments
211    ///
212    /// - `name`: the requested table name, or `None` to use the default.
213    ///
214    /// # Returns
215    ///
216    /// A reference to the [`TableEntry`] for the resolved table.
217    ///
218    /// # Errors
219    ///
220    /// - [`MiniAppError::TableRequired`] — `name` is `None` and no default
221    ///   table is configured (multi-table mode with `table` argument omitted).
222    /// - [`MiniAppError::TableNotFound`] — `name` is `Some` but the named
223    ///   table is not in the registry.
224    pub fn resolve(&self, name: Option<&str>) -> Result<&TableEntry, MiniAppError> {
225        let key = match name {
226            Some(n) => n,
227            None => match &self.default_table {
228                Some(d) => d.as_str(),
229                None => return Err(MiniAppError::TableRequired),
230            },
231        };
232
233        self.entries
234            .get(key)
235            .ok_or_else(|| MiniAppError::TableNotFound {
236                table: key.to_string(),
237            })
238    }
239
240    /// Returns the default table name, if any.
241    ///
242    /// `Some` when this registry was built via [`mount_legacy`] (single-table
243    /// mode). `None` in multi-table mode.
244    ///
245    /// [`mount_legacy`]: TableRegistry::mount_legacy
246    ///
247    /// # Returns
248    ///
249    /// `Some(&str)` with the default table name, or `None`.
250    pub fn default_table(&self) -> Option<&str> {
251        self.default_table.as_deref()
252    }
253
254    /// Returns the number of tables currently mounted in the registry.
255    ///
256    /// # Returns
257    ///
258    /// The count of mounted tables.
259    pub fn table_count(&self) -> usize {
260        self.entries.len()
261    }
262
263    /// Returns an iterator over all mounted table names.
264    ///
265    /// The iteration order is not guaranteed.
266    ///
267    /// # Returns
268    ///
269    /// An iterator yielding `&str` table names.
270    pub fn table_names(&self) -> impl Iterator<Item = &str> {
271        self.entries.keys().map(|k| k.as_str())
272    }
273
274    /// Returns the global alias storage handle if available.
275    ///
276    /// `Some` when this registry was built via
277    /// [`TableRegistry::mount_from_dirs`] with at least one of
278    /// `user_dir` / `project_dir` populated (Phase 2 multi-table mode).
279    /// `None` in legacy single-table mode and in test-only
280    /// [`TableRegistry::from_entries`] / [`TableRegistry::from_single`]
281    /// constructors.
282    pub fn global_aliases(&self) -> Option<&Arc<GlobalAliasStorage>> {
283        self.global_aliases.as_ref()
284    }
285
286    /// Returns an immutable reference to the entries map.
287    ///
288    /// Provides read-only access to all mounted [`TableEntry`] values, keyed by
289    /// table name.  This is used by `rebuild_registry()` to diff old and new
290    /// registries, and by schema CRUD tools to look up an entry's `schema_path`
291    /// and `store` for backup / row-count operations.
292    ///
293    /// **No mutation API is exposed** — the entries HashMap is always accessed
294    /// via immutable reference so existing registry invariants are preserved.
295    pub fn entries(&self) -> &HashMap<String, TableEntry> {
296        &self.entries
297    }
298
299    /// Build a registry from a pre-constructed entry map and optional default.
300    ///
301    /// This constructor is intended for use in tests where stores are created
302    /// directly (e.g. in-memory SQLite) without going through the directory
303    /// scan path.
304    ///
305    /// # Arguments
306    ///
307    /// - `entries`: map of table names to [`TableEntry`] values.
308    /// - `default_table`: optional default table name (set for legacy compat).
309    pub fn from_entries(
310        entries: HashMap<String, TableEntry>,
311        default_table: Option<String>,
312    ) -> Self {
313        TableRegistry {
314            entries,
315            default_table,
316            global_aliases: None,
317        }
318    }
319
320    /// Build a single-entry registry from a pre-opened [`Store`] and schema.
321    ///
322    /// Sets `default_table` to `table_name` so callers can omit the `table`
323    /// argument (crux #2 legacy adapter).
324    ///
325    /// # Arguments
326    ///
327    /// - `store`: the already-opened [`Store`].
328    /// - `schema`: the parsed [`SchemaConfig`].
329    /// - `schema_path`: filesystem path to `schema.yaml`.
330    /// - `table_name`: the name to register this table under and set as default.
331    pub fn from_single(
332        store: Store,
333        schema: SchemaConfig,
334        schema_path: PathBuf,
335        table_name: String,
336    ) -> Self {
337        let entry = TableEntry {
338            store: Arc::new(store),
339            schema: Arc::new(schema),
340            schema_path: Arc::new(schema_path),
341        };
342        let mut entries = HashMap::new();
343        entries.insert(table_name.clone(), entry);
344        TableRegistry {
345            entries,
346            default_table: Some(table_name),
347            global_aliases: None,
348        }
349    }
350
351    /// Merge a legacy single-table configuration into an existing registry.
352    ///
353    /// Loads the schema from `schema_path`, opens the SQLite database at
354    /// `db_path`, and inserts the resulting entry into `self`. If the table
355    /// name is already present in the registry it is **replaced** (legacy
356    /// env takes precedence) and a `tracing::warn!` is emitted.  Also sets
357    /// `default_table` to the legacy table name so callers can omit the
358    /// `table` argument (crux #2 legacy adapter).
359    ///
360    /// # Arguments
361    ///
362    /// - `registry`: the registry to merge into (consumed and returned).
363    /// - `schema_path`: path to the `schema.yaml` file.
364    /// - `db_path`: path to the SQLite database file.
365    ///
366    /// # Errors
367    ///
368    /// - [`MiniAppError::Io`] — if `schema_path` cannot be read.
369    /// - [`MiniAppError::Schema`] — if `schema.yaml` is malformed.
370    /// - [`MiniAppError::Storage`] — if the SQLite database cannot be opened.
371    pub async fn mount_legacy_into(
372        mut registry: TableRegistry,
373        schema_path: &Path,
374        db_path: &Path,
375    ) -> Result<TableRegistry, MiniAppError> {
376        let schema = schema::load_from_path(schema_path)?;
377        let table_name = schema.table.clone();
378
379        if registry.entries.contains_key(&table_name) {
380            tracing::warn!(
381                table = %table_name,
382                "legacy table name conflicts with a dir-scanned table; legacy env takes precedence"
383            );
384        }
385
386        let store = Store::open(db_path, schema.clone()).await?;
387        let entry = TableEntry {
388            store: Arc::new(store),
389            schema: Arc::new(schema),
390            schema_path: Arc::new(schema_path.to_path_buf()),
391        };
392        registry.entries.insert(table_name.clone(), entry);
393        registry.default_table = Some(table_name);
394        Ok(registry)
395    }
396}
397
398// =============================================================================
399// Private helpers
400// =============================================================================
401
402/// Migrate the legacy per-table `_aliases` rows belonging to the
403/// tables that live directly under `dir` into the chosen `scope` of
404/// the already-open global alias storage.
405///
406/// Lossless + idempotent: only the rows present in each per-table
407/// `_aliases` are read, and `INSERT OR IGNORE` skips any name already
408/// in the destination scope so multiple registry rebuilds are safe.
409///
410/// "Tables directly under `dir`" is computed by scanning `dir` for
411/// immediate subdirectories whose name is also present in `entries`
412/// (i.e. they were successfully mounted by the preceding
413/// `scan_and_mount` pass and their `Store` handle is still in the
414/// merged entries map). This is what gives Phase 2 ST3-review
415/// finding #2 its fix: user-origin tables route their aliases to
416/// User scope, and project-origin tables route to Project scope.
417async fn migrate_per_dir_subset(
418    storage: &Arc<GlobalAliasStorage>,
419    scope: crate::alias_storage::AliasScope,
420    dir: &Path,
421    entries: &HashMap<String, TableEntry>,
422) -> Result<(), MiniAppError> {
423    let mut subset_names: Vec<String> = Vec::new();
424    let read_dir = match std::fs::read_dir(dir) {
425        Ok(rd) => rd,
426        Err(e) => {
427            // The exists() filter in mount_from_dirs should have
428            // caught this, but the dir could disappear in the window
429            // between the check and the read. Treat as "no tables to
430            // migrate" rather than failing the entire mount.
431            tracing::warn!(
432                dir = %dir.display(),
433                error = %e,
434                "migrate_per_dir_subset could not read dir; skipping"
435            );
436            return Ok(());
437        }
438    };
439    for dir_entry in read_dir.flatten() {
440        let Ok(meta) = dir_entry.metadata() else {
441            continue;
442        };
443        if !meta.is_dir() {
444            continue;
445        }
446        let Some(name) = dir_entry.file_name().to_str().map(str::to_owned) else {
447            continue;
448        };
449        if entries.contains_key(&name) {
450            subset_names.push(name);
451        }
452    }
453    let per_table: Vec<(String, Arc<std::sync::Mutex<rusqlite::Connection>>)> = subset_names
454        .iter()
455        .filter_map(|name| entries.get(name).map(|e| (name.clone(), e.store.conn())))
456        .collect();
457    if per_table.is_empty() {
458        return Ok(());
459    }
460    let migrated = storage.migrate_from_per_table(scope, per_table).await?;
461    if migrated > 0 {
462        tracing::info!(
463            migrated_rows = migrated,
464            scope = ?scope,
465            "migrated legacy per-table _aliases rows into _global_aliases"
466        );
467    }
468    Ok(())
469}
470
471/// Scan `dir` for table subdirectories and mount each into `entries`.
472///
473/// Expected layout:
474/// ```text
475/// <dir>/
476///   <table>/
477///     schema.yaml
478///     <table>.db
479/// ```
480///
481/// Subdirectories that do not contain both `schema.yaml` and `<table>.db` are
482/// skipped with a `tracing::warn!`. I/O errors from `read_dir` itself propagate
483/// as [`MiniAppError::Io`].
484///
485/// # Arguments
486///
487/// - `dir`: the directory to scan.
488/// - `entries`: the map to insert/overwrite table entries in.
489///
490/// # Errors
491///
492/// Returns [`MiniAppError::Io`] if the directory cannot be read.
493async fn scan_and_mount(
494    dir: &Path,
495    entries: &mut HashMap<String, TableEntry>,
496) -> Result<(), MiniAppError> {
497    if !dir.exists() {
498        tracing::warn!(
499            dir = %dir.display(),
500            "directory does not exist, skipping"
501        );
502        return Ok(());
503    }
504
505    let read_dir = std::fs::read_dir(dir)?;
506
507    for dir_entry_result in read_dir {
508        let dir_entry = dir_entry_result?;
509        let metadata = dir_entry.metadata()?;
510
511        if !metadata.is_dir() {
512            continue;
513        }
514
515        let table_dir = dir_entry.path();
516        let table_name = match table_dir.file_name().and_then(|n| n.to_str()) {
517            Some(n) => n.to_string(),
518            None => {
519                tracing::warn!(
520                    path = %table_dir.display(),
521                    "skipping subdirectory with non-UTF-8 name"
522                );
523                continue;
524            }
525        };
526
527        let schema_path = table_dir.join("schema.yaml");
528        let db_path = table_dir.join(format!("{table_name}.db"));
529
530        if !schema_path.exists() {
531            tracing::warn!(
532                table = %table_name,
533                path = %schema_path.display(),
534                "skipping table: schema.yaml not found"
535            );
536            continue;
537        }
538
539        if !db_path.exists() {
540            // db file doesn't exist yet — Store::open will create it.
541            // This is intentional: we allow the db to be absent on first run.
542            tracing::debug!(
543                table = %table_name,
544                path = %db_path.display(),
545                "db file absent, will be created by Store::open"
546            );
547        }
548
549        let schema = match schema::load_from_path(&schema_path) {
550            Ok(s) => s,
551            Err(e) => {
552                tracing::warn!(
553                    table = %table_name,
554                    error = %e,
555                    "skipping table: failed to parse schema.yaml"
556                );
557                continue;
558            }
559        };
560
561        let store = match Store::open(&db_path, schema.clone()).await {
562            Ok(s) => s,
563            Err(e) => {
564                tracing::warn!(
565                    table = %table_name,
566                    error = %e,
567                    "skipping table: failed to open store"
568                );
569                continue;
570            }
571        };
572
573        tracing::debug!(
574            table = %table_name,
575            schema_path = %schema_path.display(),
576            db_path = %db_path.display(),
577            "mounted table"
578        );
579
580        entries.insert(
581            table_name,
582            TableEntry {
583                store: Arc::new(store),
584                schema: Arc::new(schema),
585                schema_path: Arc::new(schema_path),
586            },
587        );
588    }
589
590    Ok(())
591}
592
593// =============================================================================
594// Tests
595// =============================================================================
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600    use std::io::Write;
601    use tempfile::TempDir;
602
603    // Helper: create a table subdirectory with a minimal schema.yaml.
604    // Returns (table_dir, schema_path).  No .db file is created; Store::open
605    // will create it.
606    fn create_table_dir(parent: &TempDir, table_name: &str, fields_yaml: &str) -> PathBuf {
607        let table_dir = parent.path().join(table_name);
608        std::fs::create_dir_all(&table_dir).expect("create table dir");
609        let schema_path = table_dir.join("schema.yaml");
610        let yaml = format!("table: {table_name}\nfields:\n{fields_yaml}\n");
611        let mut f = std::fs::File::create(&schema_path).expect("create schema.yaml");
612        f.write_all(yaml.as_bytes()).expect("write schema.yaml");
613        table_dir
614    }
615
616    // ── T1: happy-path tests ──────────────────────────────────────────────
617
618    // T1: User scope only — 2 tables are mounted
619    #[tokio::test]
620    async fn user_scope_only_mounts_two_tables() {
621        let user_dir = TempDir::new().expect("tempdir");
622        create_table_dir(
623            &user_dir,
624            "notes",
625            "  - name: title\n    type: string\n    required: true\n",
626        );
627        create_table_dir(
628            &user_dir,
629            "tasks",
630            "  - name: body\n    type: string\n    required: false\n",
631        );
632
633        let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
634            .await
635            .expect("mount must succeed");
636
637        assert_eq!(registry.table_count(), 2);
638        assert!(registry.resolve(Some("notes")).is_ok());
639        assert!(registry.resolve(Some("tasks")).is_ok());
640        assert_eq!(registry.default_table(), None);
641    }
642
643    // T1: User + Project scopes — A (User only), B (Project override), C (Project only)
644    #[tokio::test]
645    async fn user_and_project_scopes_merge_with_project_override() {
646        let user_dir = TempDir::new().expect("tempdir");
647        let project_dir = TempDir::new().expect("tempdir");
648
649        // User: table_a and table_b
650        create_table_dir(
651            &user_dir,
652            "table_a",
653            "  - name: f\n    type: string\n    required: false\n",
654        );
655        create_table_dir(
656            &user_dir,
657            "table_b",
658            "  - name: user_field\n    type: string\n    required: false\n",
659        );
660
661        // Project: table_b (override) and table_c
662        create_table_dir(
663            &project_dir,
664            "table_b",
665            "  - name: project_field\n    type: string\n    required: true\n",
666        );
667        create_table_dir(
668            &project_dir,
669            "table_c",
670            "  - name: g\n    type: number\n    required: false\n",
671        );
672
673        let registry =
674            TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
675                .await
676                .expect("mount must succeed");
677
678        assert_eq!(registry.table_count(), 3);
679
680        // table_a comes from User
681        let entry_a = registry
682            .resolve(Some("table_a"))
683            .expect("table_a must exist");
684        assert_eq!(entry_a.schema.table, "table_a");
685
686        // table_b comes from Project (overrides User)
687        let entry_b = registry
688            .resolve(Some("table_b"))
689            .expect("table_b must exist");
690        // Project's schema has "project_field" as required=true; User had "user_field"
691        assert!(
692            entry_b
693                .schema
694                .fields
695                .iter()
696                .any(|f| f.name == "project_field"),
697            "table_b should use Project schema (project_field), not User schema (user_field)"
698        );
699        assert!(
700            !entry_b.schema.fields.iter().any(|f| f.name == "user_field"),
701            "table_b must not retain User's user_field after Project override"
702        );
703
704        // table_c comes from Project only
705        assert!(registry.resolve(Some("table_c")).is_ok());
706    }
707
708    // T1: Project override is file-level swap (not field merge)
709    #[tokio::test]
710    async fn project_override_is_file_level_swap_not_field_merge() {
711        let user_dir = TempDir::new().expect("tempdir");
712        let project_dir = TempDir::new().expect("tempdir");
713
714        // User: same_table with field_a + field_b
715        create_table_dir(
716            &user_dir,
717            "same_table",
718            "  - name: field_a\n    type: string\n    required: false\n  - name: field_b\n    type: string\n    required: false\n",
719        );
720        // Project: same_table with only field_c
721        create_table_dir(
722            &project_dir,
723            "same_table",
724            "  - name: field_c\n    type: number\n    required: true\n",
725        );
726
727        let registry =
728            TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
729                .await
730                .expect("mount must succeed");
731
732        let entry = registry
733            .resolve(Some("same_table"))
734            .expect("same_table must exist");
735        // Only Project fields — User fields must not appear
736        assert_eq!(entry.schema.fields.len(), 1);
737        assert_eq!(entry.schema.fields[0].name, "field_c");
738    }
739
740    // T1: legacy env mode — 1 table + default_table set
741    #[tokio::test]
742    async fn legacy_mode_mounts_one_table_with_default() {
743        let dir = TempDir::new().expect("tempdir");
744        let schema_path = dir.path().join("schema.yaml");
745        let db_path = dir.path().join("legacy.db");
746
747        let yaml =
748            "table: legacy_table\nfields:\n  - name: title\n    type: string\n    required: true\n";
749        std::fs::write(&schema_path, yaml).expect("write schema.yaml");
750
751        let registry = TableRegistry::mount_legacy(&schema_path, &db_path)
752            .await
753            .expect("mount_legacy must succeed");
754
755        assert_eq!(registry.table_count(), 1);
756        assert_eq!(registry.default_table(), Some("legacy_table"));
757
758        // Resolving with None uses the default
759        let entry = registry
760            .resolve(None)
761            .expect("default resolve must succeed");
762        assert_eq!(entry.schema.table, "legacy_table");
763
764        // Resolving with explicit name also works
765        let entry2 = registry
766            .resolve(Some("legacy_table"))
767            .expect("explicit resolve must succeed");
768        assert_eq!(entry2.schema.table, "legacy_table");
769    }
770
771    // ── T2: boundary / edge-case tests ───────────────────────────────────
772
773    // T2: empty user_dir + empty project_dir → 0 tables mounted
774    #[tokio::test]
775    async fn empty_dirs_mount_zero_tables() {
776        let user_dir = TempDir::new().expect("tempdir");
777        let project_dir = TempDir::new().expect("tempdir");
778
779        let registry =
780            TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
781                .await
782                .expect("mount must not fail for empty dirs");
783
784        assert_eq!(registry.table_count(), 0);
785    }
786
787    // T2: both dirs are None → 0 tables, no error
788    #[tokio::test]
789    async fn both_dirs_none_mounts_zero_tables() {
790        let registry = TableRegistry::mount_from_dirs(None, None)
791            .await
792            .expect("mount must not fail when both dirs are None");
793
794        assert_eq!(registry.table_count(), 0);
795    }
796
797    // T2: non-existent dir is skipped with warn, not fatal
798    #[tokio::test]
799    async fn nonexistent_dir_is_skipped_not_fatal() {
800        let user_dir = TempDir::new().expect("tempdir");
801        create_table_dir(
802            &user_dir,
803            "table_a",
804            "  - name: f\n    type: string\n    required: false\n",
805        );
806
807        let nonexistent = PathBuf::from("/nonexistent/path/that/does/not/exist");
808        let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(&nonexistent))
809            .await
810            .expect("mount must succeed even when project_dir does not exist");
811
812        // Only table_a from user_dir should be mounted
813        assert_eq!(registry.table_count(), 1);
814        assert!(registry.resolve(Some("table_a")).is_ok());
815    }
816
817    // T2: subdir with no schema.yaml is skipped
818    #[tokio::test]
819    async fn subdir_without_schema_yaml_is_skipped() {
820        let user_dir = TempDir::new().expect("tempdir");
821        // Create a subdir but no schema.yaml
822        std::fs::create_dir(user_dir.path().join("no_schema")).expect("create dir");
823        // Also create a valid table
824        create_table_dir(
825            &user_dir,
826            "valid_table",
827            "  - name: f\n    type: string\n    required: false\n",
828        );
829
830        let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
831            .await
832            .expect("mount must succeed");
833
834        assert_eq!(registry.table_count(), 1);
835        assert!(registry.resolve(Some("valid_table")).is_ok());
836    }
837
838    // ── T3: error-path tests ─────────────────────────────────────────────
839
840    // T3: resolve with None and no default → TableRequired
841    #[tokio::test]
842    async fn resolve_none_without_default_returns_table_required() {
843        let user_dir = TempDir::new().expect("tempdir");
844        create_table_dir(
845            &user_dir,
846            "table_a",
847            "  - name: f\n    type: string\n    required: false\n",
848        );
849        create_table_dir(
850            &user_dir,
851            "table_b",
852            "  - name: g\n    type: string\n    required: false\n",
853        );
854
855        let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
856            .await
857            .expect("mount must succeed");
858
859        let result = registry.resolve(None);
860        assert!(
861            result.is_err(),
862            "resolve(None) must fail with no default table"
863        );
864        // SAFETY: we just asserted is_err() so this will not panic
865        if let Err(err) = result {
866            assert!(
867                matches!(err, MiniAppError::TableRequired),
868                "expected TableRequired, got: {err:?}"
869            );
870        }
871    }
872
873    // T3: resolve unknown table name → TableNotFound with correct table name
874    #[tokio::test]
875    async fn resolve_unknown_table_returns_table_not_found() {
876        let user_dir = TempDir::new().expect("tempdir");
877        create_table_dir(
878            &user_dir,
879            "table_a",
880            "  - name: f\n    type: string\n    required: false\n",
881        );
882
883        let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
884            .await
885            .expect("mount must succeed");
886
887        let result = registry.resolve(Some("nonexistent"));
888        assert!(result.is_err(), "resolve(nonexistent) must fail");
889        // SAFETY: we just asserted is_err() so this will not panic
890        if let Err(err) = result {
891            match err {
892                MiniAppError::TableNotFound { table } => {
893                    assert_eq!(table, "nonexistent");
894                }
895                other => panic!("expected TableNotFound, got: {other:?}"),
896            }
897        }
898    }
899
900    // (rmcp-dependent variant→McpError conversion tests live in
901    // `crates/mcp/src/error_conv.rs` to honor the one-way `mcp → core` dep
902    // boundary, Outline rust book §5-1-10 K-orphan-rule.)
903
904    // ── Phase 2 (ST1b): global alias storage integration ─────────────────
905
906    // mount_from_dirs opens GlobalAliasStorage and exposes it via the
907    // global_aliases() accessor.
908    #[tokio::test]
909    async fn mount_from_dirs_attaches_global_alias_storage() {
910        let user_dir = TempDir::new().expect("tempdir");
911        create_table_dir(
912            &user_dir,
913            "rows",
914            "  - name: f\n    type: string\n    required: false\n",
915        );
916        let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
917            .await
918            .expect("mount must succeed");
919        assert!(
920            registry.global_aliases().is_some(),
921            "mount_from_dirs with user_dir must attach GlobalAliasStorage"
922        );
923    }
924
925    // mount_from_dirs(None, None) → no global alias storage.
926    #[tokio::test]
927    async fn mount_from_dirs_without_any_dir_has_no_global_alias_storage() {
928        let registry = TableRegistry::mount_from_dirs(None, None)
929            .await
930            .expect("mount must succeed even with no dirs");
931        assert!(
932            registry.global_aliases().is_none(),
933            "mount_from_dirs(None, None) must not attach GlobalAliasStorage"
934        );
935    }
936
937    // Legacy single-table mode does not own a global alias storage.
938    #[tokio::test]
939    async fn mount_legacy_has_no_global_alias_storage() {
940        let dir = TempDir::new().expect("tempdir");
941        let schema_path = dir.path().join("schema.yaml");
942        std::fs::write(
943            &schema_path,
944            "table: notes\nfields:\n  - name: title\n    type: string\n    required: true\n",
945        )
946        .expect("write schema");
947        let db_path = dir.path().join("notes.db");
948        let registry = TableRegistry::mount_legacy(&schema_path, &db_path)
949            .await
950            .expect("mount_legacy must succeed");
951        assert!(
952            registry.global_aliases().is_none(),
953            "mount_legacy must not attach GlobalAliasStorage"
954        );
955    }
956
957    // Per-scope migration routing (Phase 2 review fix #2): user-origin
958    // tables migrate to User scope, project-origin tables migrate to
959    // Project scope — preserving the "user-scope alias follows user
960    // across projects" intent.
961    #[tokio::test]
962    async fn mount_from_dirs_routes_per_table_aliases_to_origin_scope() {
963        use crate::alias_storage::{AliasScope, LEGACY_PER_TABLE_ALIASES_SQL};
964
965        let user_dir = TempDir::new().expect("tempdir");
966        let project_dir = TempDir::new().expect("tempdir");
967
968        // User-origin table `user_only` carrying a legacy alias.
969        let user_table = create_table_dir(
970            &user_dir,
971            "user_only",
972            "  - name: f\n    type: string\n    required: false\n",
973        );
974        let user_db = user_table.join("user_only.db");
975        let conn = rusqlite::Connection::open(&user_db).expect("open user db");
976        conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
977        conn.execute(
978            "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
979             VALUES (?1, ?2, ?3, ?4, ?5)",
980            rusqlite::params![
981                "from_user",
982                "{}",
983                Some(7i64),
984                Some("user-scope alias".to_string()),
985                Option::<String>::None
986            ],
987        )
988        .unwrap();
989        drop(conn);
990
991        // Project-origin table `proj_only` carrying a legacy alias.
992        let proj_table = create_table_dir(
993            &project_dir,
994            "proj_only",
995            "  - name: f\n    type: string\n    required: false\n",
996        );
997        let proj_db = proj_table.join("proj_only.db");
998        let conn = rusqlite::Connection::open(&proj_db).expect("open proj db");
999        conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
1000        conn.execute(
1001            "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
1002             VALUES (?1, ?2, ?3, ?4, ?5)",
1003            rusqlite::params![
1004                "from_project",
1005                "{}",
1006                Some(11i64),
1007                Some("project-scope alias".to_string()),
1008                Option::<String>::None
1009            ],
1010        )
1011        .unwrap();
1012        drop(conn);
1013
1014        let registry =
1015            TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
1016                .await
1017                .expect("mount must succeed");
1018        let global = registry
1019            .global_aliases()
1020            .expect("global storage must be attached");
1021
1022        // user-origin alias must land in User scope.
1023        let user_alias = global
1024            .alias_get_scope(AliasScope::User, "from_user")
1025            .await
1026            .expect("user alias_get_scope ok")
1027            .expect("user alias must be present in User scope");
1028        assert_eq!(user_alias.description.as_deref(), Some("user-scope alias"));
1029        // and NOT in Project scope.
1030        let user_in_project = global
1031            .alias_get_scope(AliasScope::Project, "from_user")
1032            .await
1033            .expect("project alias_get_scope ok");
1034        assert!(
1035            user_in_project.is_none(),
1036            "user-origin alias must NOT leak into Project scope (silent inversion fix)"
1037        );
1038
1039        // project-origin alias must land in Project scope.
1040        let proj_alias = global
1041            .alias_get_scope(AliasScope::Project, "from_project")
1042            .await
1043            .expect("project alias_get_scope ok")
1044            .expect("project alias must be present in Project scope");
1045        assert_eq!(
1046            proj_alias.description.as_deref(),
1047            Some("project-scope alias")
1048        );
1049        // and NOT in User scope.
1050        let proj_in_user = global
1051            .alias_get_scope(AliasScope::User, "from_project")
1052            .await
1053            .expect("user alias_get_scope ok");
1054        assert!(
1055            proj_in_user.is_none(),
1056            "project-origin alias must NOT leak into User scope"
1057        );
1058    }
1059
1060    // Same name in both scopes: user-origin → User, project-origin →
1061    // Project. alias_get returns Project (precedence rule), but each
1062    // scope independently keeps its own row so a later project-less
1063    // mount can still see the User entry.
1064    #[tokio::test]
1065    async fn mount_from_dirs_preserves_user_alias_when_project_overrides_table() {
1066        use crate::alias_storage::{AliasScope, LEGACY_PER_TABLE_ALIASES_SQL};
1067
1068        let user_dir = TempDir::new().expect("tempdir");
1069        let project_dir = TempDir::new().expect("tempdir");
1070
1071        // user_dir/foo with alias "shared" (description: "user").
1072        let user_table = create_table_dir(
1073            &user_dir,
1074            "foo",
1075            "  - name: f\n    type: string\n    required: false\n",
1076        );
1077        let user_db = user_table.join("foo.db");
1078        let conn = rusqlite::Connection::open(&user_db).expect("open user foo db");
1079        conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
1080        conn.execute(
1081            "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
1082             VALUES (?1, ?2, ?3, ?4, ?5)",
1083            rusqlite::params![
1084                "shared",
1085                "{}",
1086                Option::<i64>::None,
1087                Some("user".to_string()),
1088                Option::<String>::None
1089            ],
1090        )
1091        .unwrap();
1092        drop(conn);
1093
1094        // project_dir/foo (overrides user_dir/foo) with alias "shared" (description: "project").
1095        let proj_table = create_table_dir(
1096            &project_dir,
1097            "foo",
1098            "  - name: f\n    type: string\n    required: false\n",
1099        );
1100        let proj_db = proj_table.join("foo.db");
1101        let conn = rusqlite::Connection::open(&proj_db).expect("open project foo db");
1102        conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
1103        conn.execute(
1104            "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
1105             VALUES (?1, ?2, ?3, ?4, ?5)",
1106            rusqlite::params![
1107                "shared",
1108                "{}",
1109                Option::<i64>::None,
1110                Some("project".to_string()),
1111                Option::<String>::None
1112            ],
1113        )
1114        .unwrap();
1115        drop(conn);
1116
1117        let registry =
1118            TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
1119                .await
1120                .expect("mount must succeed");
1121        let global = registry
1122            .global_aliases()
1123            .expect("global storage must be attached");
1124
1125        // User scope row preserved with user-origin description.
1126        let user_row = global
1127            .alias_get_scope(AliasScope::User, "shared")
1128            .await
1129            .unwrap()
1130            .expect("user shared alias preserved");
1131        assert_eq!(user_row.description.as_deref(), Some("user"));
1132
1133        // Project scope row has project-origin description.
1134        let project_row = global
1135            .alias_get_scope(AliasScope::Project, "shared")
1136            .await
1137            .unwrap()
1138            .expect("project shared alias present");
1139        assert_eq!(project_row.description.as_deref(), Some("project"));
1140
1141        // alias_get applies the Project → User precedence rule.
1142        let merged = global.alias_get("shared").await.unwrap();
1143        assert_eq!(merged.description.as_deref(), Some("project"));
1144        assert_eq!(merged.scope, Some(AliasScope::Project));
1145    }
1146
1147    // mount_from_dirs auto-migrates any pre-existing per-table _aliases
1148    // rows into the project-scope _global_aliases (lossless, the rows
1149    // become visible through alias_list with sources=Single(<table>)).
1150    #[tokio::test]
1151    async fn mount_from_dirs_auto_migrates_per_table_aliases() {
1152        use crate::alias_storage::LEGACY_PER_TABLE_ALIASES_SQL;
1153
1154        let project_dir = TempDir::new().expect("tempdir");
1155        let table_dir = create_table_dir(
1156            &project_dir,
1157            "rows",
1158            "  - name: f\n    type: string\n    required: false\n",
1159        );
1160
1161        // Pre-create the rows.db with a legacy _aliases row before mount
1162        // so the auto-migration sees something to copy.
1163        let db_path = table_dir.join("rows.db");
1164        let conn = rusqlite::Connection::open(&db_path).expect("open per-table db");
1165        conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL)
1166            .expect("create _aliases");
1167        conn.execute(
1168            "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
1169             VALUES (?1, ?2, ?3, ?4, ?5)",
1170            rusqlite::params![
1171                "legacy_alias",
1172                "{}",
1173                Some(10i64),
1174                Some("preserved".to_string()),
1175                Option::<String>::None
1176            ],
1177        )
1178        .expect("seed legacy alias");
1179        drop(conn);
1180
1181        let registry = TableRegistry::mount_from_dirs(None, Some(project_dir.path()))
1182            .await
1183            .expect("mount must succeed");
1184        let global = registry
1185            .global_aliases()
1186            .expect("global storage must be attached");
1187        let all = global.alias_list().await.expect("alias_list");
1188        assert_eq!(all.len(), 1, "exactly one alias should be migrated");
1189        let rec = &all[0];
1190        assert_eq!(rec.name, "legacy_alias");
1191        assert!(
1192            matches!(&rec.sources, crate::aggregator::SourceSpec::Single(t) if t == "rows"),
1193            "migrated row must have sources=Single(rows), got {:?}",
1194            rec.sources
1195        );
1196        assert!(rec.aggregator.is_none());
1197        assert_eq!(rec.default_limit, Some(10));
1198        assert_eq!(rec.description.as_deref(), Some("preserved"));
1199    }
1200}