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::error::MiniAppError;
31use crate::schema::{self, SchemaConfig};
32use crate::store::Store;
33
34// =============================================================================
35// TableRegistry
36// =============================================================================
37
38/// Resolved entry for a single mounted table.
39///
40/// Holds the `Arc`-wrapped store, schema, and schema file path so consumers
41/// can access all three without a separate map lookup per field.
42pub struct TableEntry {
43    /// The running store for this table.
44    pub store: Arc<Store>,
45    /// The parsed schema configuration.
46    pub schema: Arc<SchemaConfig>,
47    /// Filesystem path to `schema.yaml` (used for lazy schema resource reads).
48    pub schema_path: Arc<PathBuf>,
49}
50
51/// Registry of all mounted tables for the current server instance.
52///
53/// Build with [`TableRegistry::mount_from_dirs`] (multi-table) or
54/// [`TableRegistry::mount_legacy`] (single-table legacy mode). After
55/// construction the registry is immutable.
56pub struct TableRegistry {
57    /// All mounted tables keyed by table name.
58    entries: HashMap<String, TableEntry>,
59    /// The default table name, set only in legacy single-table mode.
60    default_table: Option<String>,
61}
62
63impl TableRegistry {
64    /// Mount tables discovered from the User-scope and Project-scope directories.
65    ///
66    /// This is the **crux #1** entry point. It performs a two-phase scan:
67    /// 1. Scan `user_dir` (base layer): every subdirectory `<table>/` that
68    ///    contains both `schema.yaml` and `<table>.db` is mounted.
69    /// 2. Scan `project_dir` (override layer): same discovery, but any table
70    ///    name already present from the User scan is **replaced** (file-level
71    ///    swap, not field-level merge).
72    ///
73    /// Either argument may be `None` (e.g. if the directory does not exist or
74    /// was not configured). A non-existent directory is skipped with a
75    /// `tracing::warn!`; it is not a fatal error.
76    ///
77    /// # Arguments
78    ///
79    /// - `user_dir`: path to the User-scope directory (e.g. `~/.mini-app/`).
80    ///   Subdirectories represent table names.
81    /// - `project_dir`: path to the Project-scope directory (e.g.
82    ///   `./.mini-app/`). Overrides same-named User tables.
83    ///
84    /// # Returns
85    ///
86    /// A [`TableRegistry`] with all discovered tables mounted and
87    /// `default_table = None` (no default in multi-table mode).
88    ///
89    /// # Errors
90    ///
91    /// Returns [`MiniAppError::Io`] if a directory can be opened but
92    /// `read_dir` or file reads fail. Missing directories are skipped, not
93    /// treated as errors.
94    pub async fn mount_from_dirs(
95        user_dir: Option<&Path>,
96        project_dir: Option<&Path>,
97    ) -> Result<Self, MiniAppError> {
98        let mut entries: HashMap<String, TableEntry> = HashMap::new();
99
100        // Phase 1: User scope (base layer)
101        if let Some(dir) = user_dir {
102            scan_and_mount(dir, &mut entries).await?;
103        }
104
105        // Phase 2: Project scope (override layer — same-named tables replace User)
106        if let Some(dir) = project_dir {
107            scan_and_mount(dir, &mut entries).await?;
108        }
109
110        Ok(TableRegistry {
111            entries,
112            default_table: None,
113        })
114    }
115
116    /// Mount a single legacy table from explicit `schema_path` and `db_path`.
117    ///
118    /// This is the **crux #2** entry point. It registers the table described by
119    /// `schema_path` and sets `default_table` to that table's name so callers
120    /// can omit the `table` argument when using [`TableRegistry::resolve`].
121    ///
122    /// # Arguments
123    ///
124    /// - `schema_path`: path to the `schema.yaml` file.
125    /// - `db_path`: path to the SQLite database file.
126    ///
127    /// # Returns
128    ///
129    /// A [`TableRegistry`] with a single table mounted and
130    /// `default_table = Some(<table_name>)`.
131    ///
132    /// # Errors
133    ///
134    /// - [`MiniAppError::Io`] — if `schema_path` cannot be read.
135    /// - [`MiniAppError::Schema`] — if `schema.yaml` is malformed.
136    /// - [`MiniAppError::Storage`] — if the SQLite database cannot be opened.
137    pub async fn mount_legacy(schema_path: &Path, db_path: &Path) -> Result<Self, MiniAppError> {
138        let schema = schema::load_from_path(schema_path)?;
139        let table_name = schema.table.clone();
140        let store = Store::open(db_path, schema.clone()).await?;
141
142        let entry = TableEntry {
143            store: Arc::new(store),
144            schema: Arc::new(schema),
145            schema_path: Arc::new(schema_path.to_path_buf()),
146        };
147
148        let mut entries = HashMap::new();
149        entries.insert(table_name.clone(), entry);
150
151        Ok(TableRegistry {
152            entries,
153            default_table: Some(table_name),
154        })
155    }
156
157    /// Resolve a table by name, falling back to `default_table` when `name` is
158    /// `None`.
159    ///
160    /// This is the **crux #2** runtime entry point. When a single-table legacy
161    /// env is set and `default_table` is `Some`, `name = None` is allowed and
162    /// returns the default entry. In multi-table mode (`default_table = None`)
163    /// `name` must be `Some`.
164    ///
165    /// # Arguments
166    ///
167    /// - `name`: the requested table name, or `None` to use the default.
168    ///
169    /// # Returns
170    ///
171    /// A reference to the [`TableEntry`] for the resolved table.
172    ///
173    /// # Errors
174    ///
175    /// - [`MiniAppError::TableRequired`] — `name` is `None` and no default
176    ///   table is configured (multi-table mode with `table` argument omitted).
177    /// - [`MiniAppError::TableNotFound`] — `name` is `Some` but the named
178    ///   table is not in the registry.
179    pub fn resolve(&self, name: Option<&str>) -> Result<&TableEntry, MiniAppError> {
180        let key = match name {
181            Some(n) => n,
182            None => match &self.default_table {
183                Some(d) => d.as_str(),
184                None => return Err(MiniAppError::TableRequired),
185            },
186        };
187
188        self.entries
189            .get(key)
190            .ok_or_else(|| MiniAppError::TableNotFound {
191                table: key.to_string(),
192            })
193    }
194
195    /// Returns the default table name, if any.
196    ///
197    /// `Some` when this registry was built via [`mount_legacy`] (single-table
198    /// mode). `None` in multi-table mode.
199    ///
200    /// [`mount_legacy`]: TableRegistry::mount_legacy
201    ///
202    /// # Returns
203    ///
204    /// `Some(&str)` with the default table name, or `None`.
205    pub fn default_table(&self) -> Option<&str> {
206        self.default_table.as_deref()
207    }
208
209    /// Returns the number of tables currently mounted in the registry.
210    ///
211    /// # Returns
212    ///
213    /// The count of mounted tables.
214    pub fn table_count(&self) -> usize {
215        self.entries.len()
216    }
217
218    /// Returns an iterator over all mounted table names.
219    ///
220    /// The iteration order is not guaranteed.
221    ///
222    /// # Returns
223    ///
224    /// An iterator yielding `&str` table names.
225    pub fn table_names(&self) -> impl Iterator<Item = &str> {
226        self.entries.keys().map(|k| k.as_str())
227    }
228
229    /// Returns an immutable reference to the entries map.
230    ///
231    /// Provides read-only access to all mounted [`TableEntry`] values, keyed by
232    /// table name.  This is used by `rebuild_registry()` to diff old and new
233    /// registries, and by schema CRUD tools to look up an entry's `schema_path`
234    /// and `store` for backup / row-count operations.
235    ///
236    /// **No mutation API is exposed** — the entries HashMap is always accessed
237    /// via immutable reference so existing registry invariants are preserved.
238    pub fn entries(&self) -> &HashMap<String, TableEntry> {
239        &self.entries
240    }
241
242    /// Build a registry from a pre-constructed entry map and optional default.
243    ///
244    /// This constructor is intended for use in tests where stores are created
245    /// directly (e.g. in-memory SQLite) without going through the directory
246    /// scan path.
247    ///
248    /// # Arguments
249    ///
250    /// - `entries`: map of table names to [`TableEntry`] values.
251    /// - `default_table`: optional default table name (set for legacy compat).
252    pub fn from_entries(
253        entries: HashMap<String, TableEntry>,
254        default_table: Option<String>,
255    ) -> Self {
256        TableRegistry {
257            entries,
258            default_table,
259        }
260    }
261
262    /// Build a single-entry registry from a pre-opened [`Store`] and schema.
263    ///
264    /// Sets `default_table` to `table_name` so callers can omit the `table`
265    /// argument (crux #2 legacy adapter).
266    ///
267    /// # Arguments
268    ///
269    /// - `store`: the already-opened [`Store`].
270    /// - `schema`: the parsed [`SchemaConfig`].
271    /// - `schema_path`: filesystem path to `schema.yaml`.
272    /// - `table_name`: the name to register this table under and set as default.
273    pub fn from_single(
274        store: Store,
275        schema: SchemaConfig,
276        schema_path: PathBuf,
277        table_name: String,
278    ) -> Self {
279        let entry = TableEntry {
280            store: Arc::new(store),
281            schema: Arc::new(schema),
282            schema_path: Arc::new(schema_path),
283        };
284        let mut entries = HashMap::new();
285        entries.insert(table_name.clone(), entry);
286        TableRegistry {
287            entries,
288            default_table: Some(table_name),
289        }
290    }
291
292    /// Merge a legacy single-table configuration into an existing registry.
293    ///
294    /// Loads the schema from `schema_path`, opens the SQLite database at
295    /// `db_path`, and inserts the resulting entry into `self`. If the table
296    /// name is already present in the registry it is **replaced** (legacy
297    /// env takes precedence) and a `tracing::warn!` is emitted.  Also sets
298    /// `default_table` to the legacy table name so callers can omit the
299    /// `table` argument (crux #2 legacy adapter).
300    ///
301    /// # Arguments
302    ///
303    /// - `registry`: the registry to merge into (consumed and returned).
304    /// - `schema_path`: path to the `schema.yaml` file.
305    /// - `db_path`: path to the SQLite database file.
306    ///
307    /// # Errors
308    ///
309    /// - [`MiniAppError::Io`] — if `schema_path` cannot be read.
310    /// - [`MiniAppError::Schema`] — if `schema.yaml` is malformed.
311    /// - [`MiniAppError::Storage`] — if the SQLite database cannot be opened.
312    pub async fn mount_legacy_into(
313        mut registry: TableRegistry,
314        schema_path: &Path,
315        db_path: &Path,
316    ) -> Result<TableRegistry, MiniAppError> {
317        let schema = schema::load_from_path(schema_path)?;
318        let table_name = schema.table.clone();
319
320        if registry.entries.contains_key(&table_name) {
321            tracing::warn!(
322                table = %table_name,
323                "legacy table name conflicts with a dir-scanned table; legacy env takes precedence"
324            );
325        }
326
327        let store = Store::open(db_path, schema.clone()).await?;
328        let entry = TableEntry {
329            store: Arc::new(store),
330            schema: Arc::new(schema),
331            schema_path: Arc::new(schema_path.to_path_buf()),
332        };
333        registry.entries.insert(table_name.clone(), entry);
334        registry.default_table = Some(table_name);
335        Ok(registry)
336    }
337}
338
339// =============================================================================
340// Private helpers
341// =============================================================================
342
343/// Scan `dir` for table subdirectories and mount each into `entries`.
344///
345/// Expected layout:
346/// ```text
347/// <dir>/
348///   <table>/
349///     schema.yaml
350///     <table>.db
351/// ```
352///
353/// Subdirectories that do not contain both `schema.yaml` and `<table>.db` are
354/// skipped with a `tracing::warn!`. I/O errors from `read_dir` itself propagate
355/// as [`MiniAppError::Io`].
356///
357/// # Arguments
358///
359/// - `dir`: the directory to scan.
360/// - `entries`: the map to insert/overwrite table entries in.
361///
362/// # Errors
363///
364/// Returns [`MiniAppError::Io`] if the directory cannot be read.
365async fn scan_and_mount(
366    dir: &Path,
367    entries: &mut HashMap<String, TableEntry>,
368) -> Result<(), MiniAppError> {
369    if !dir.exists() {
370        tracing::warn!(
371            dir = %dir.display(),
372            "directory does not exist, skipping"
373        );
374        return Ok(());
375    }
376
377    let read_dir = std::fs::read_dir(dir)?;
378
379    for dir_entry_result in read_dir {
380        let dir_entry = dir_entry_result?;
381        let metadata = dir_entry.metadata()?;
382
383        if !metadata.is_dir() {
384            continue;
385        }
386
387        let table_dir = dir_entry.path();
388        let table_name = match table_dir.file_name().and_then(|n| n.to_str()) {
389            Some(n) => n.to_string(),
390            None => {
391                tracing::warn!(
392                    path = %table_dir.display(),
393                    "skipping subdirectory with non-UTF-8 name"
394                );
395                continue;
396            }
397        };
398
399        let schema_path = table_dir.join("schema.yaml");
400        let db_path = table_dir.join(format!("{table_name}.db"));
401
402        if !schema_path.exists() {
403            tracing::warn!(
404                table = %table_name,
405                path = %schema_path.display(),
406                "skipping table: schema.yaml not found"
407            );
408            continue;
409        }
410
411        if !db_path.exists() {
412            // db file doesn't exist yet — Store::open will create it.
413            // This is intentional: we allow the db to be absent on first run.
414            tracing::debug!(
415                table = %table_name,
416                path = %db_path.display(),
417                "db file absent, will be created by Store::open"
418            );
419        }
420
421        let schema = match schema::load_from_path(&schema_path) {
422            Ok(s) => s,
423            Err(e) => {
424                tracing::warn!(
425                    table = %table_name,
426                    error = %e,
427                    "skipping table: failed to parse schema.yaml"
428                );
429                continue;
430            }
431        };
432
433        let store = match Store::open(&db_path, schema.clone()).await {
434            Ok(s) => s,
435            Err(e) => {
436                tracing::warn!(
437                    table = %table_name,
438                    error = %e,
439                    "skipping table: failed to open store"
440                );
441                continue;
442            }
443        };
444
445        tracing::debug!(
446            table = %table_name,
447            schema_path = %schema_path.display(),
448            db_path = %db_path.display(),
449            "mounted table"
450        );
451
452        entries.insert(
453            table_name,
454            TableEntry {
455                store: Arc::new(store),
456                schema: Arc::new(schema),
457                schema_path: Arc::new(schema_path),
458            },
459        );
460    }
461
462    Ok(())
463}
464
465// =============================================================================
466// Tests
467// =============================================================================
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use std::io::Write;
473    use tempfile::TempDir;
474
475    // Helper: create a table subdirectory with a minimal schema.yaml.
476    // Returns (table_dir, schema_path).  No .db file is created; Store::open
477    // will create it.
478    fn create_table_dir(parent: &TempDir, table_name: &str, fields_yaml: &str) -> PathBuf {
479        let table_dir = parent.path().join(table_name);
480        std::fs::create_dir_all(&table_dir).expect("create table dir");
481        let schema_path = table_dir.join("schema.yaml");
482        let yaml = format!("table: {table_name}\nfields:\n{fields_yaml}\n");
483        let mut f = std::fs::File::create(&schema_path).expect("create schema.yaml");
484        f.write_all(yaml.as_bytes()).expect("write schema.yaml");
485        table_dir
486    }
487
488    // ── T1: happy-path tests ──────────────────────────────────────────────
489
490    // T1: User scope only — 2 tables are mounted
491    #[tokio::test]
492    async fn user_scope_only_mounts_two_tables() {
493        let user_dir = TempDir::new().expect("tempdir");
494        create_table_dir(
495            &user_dir,
496            "notes",
497            "  - name: title\n    type: string\n    required: true\n",
498        );
499        create_table_dir(
500            &user_dir,
501            "tasks",
502            "  - name: body\n    type: string\n    required: false\n",
503        );
504
505        let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
506            .await
507            .expect("mount must succeed");
508
509        assert_eq!(registry.table_count(), 2);
510        assert!(registry.resolve(Some("notes")).is_ok());
511        assert!(registry.resolve(Some("tasks")).is_ok());
512        assert_eq!(registry.default_table(), None);
513    }
514
515    // T1: User + Project scopes — A (User only), B (Project override), C (Project only)
516    #[tokio::test]
517    async fn user_and_project_scopes_merge_with_project_override() {
518        let user_dir = TempDir::new().expect("tempdir");
519        let project_dir = TempDir::new().expect("tempdir");
520
521        // User: table_a and table_b
522        create_table_dir(
523            &user_dir,
524            "table_a",
525            "  - name: f\n    type: string\n    required: false\n",
526        );
527        create_table_dir(
528            &user_dir,
529            "table_b",
530            "  - name: user_field\n    type: string\n    required: false\n",
531        );
532
533        // Project: table_b (override) and table_c
534        create_table_dir(
535            &project_dir,
536            "table_b",
537            "  - name: project_field\n    type: string\n    required: true\n",
538        );
539        create_table_dir(
540            &project_dir,
541            "table_c",
542            "  - name: g\n    type: number\n    required: false\n",
543        );
544
545        let registry =
546            TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
547                .await
548                .expect("mount must succeed");
549
550        assert_eq!(registry.table_count(), 3);
551
552        // table_a comes from User
553        let entry_a = registry
554            .resolve(Some("table_a"))
555            .expect("table_a must exist");
556        assert_eq!(entry_a.schema.table, "table_a");
557
558        // table_b comes from Project (overrides User)
559        let entry_b = registry
560            .resolve(Some("table_b"))
561            .expect("table_b must exist");
562        // Project's schema has "project_field" as required=true; User had "user_field"
563        assert!(
564            entry_b
565                .schema
566                .fields
567                .iter()
568                .any(|f| f.name == "project_field"),
569            "table_b should use Project schema (project_field), not User schema (user_field)"
570        );
571        assert!(
572            !entry_b.schema.fields.iter().any(|f| f.name == "user_field"),
573            "table_b must not retain User's user_field after Project override"
574        );
575
576        // table_c comes from Project only
577        assert!(registry.resolve(Some("table_c")).is_ok());
578    }
579
580    // T1: Project override is file-level swap (not field merge)
581    #[tokio::test]
582    async fn project_override_is_file_level_swap_not_field_merge() {
583        let user_dir = TempDir::new().expect("tempdir");
584        let project_dir = TempDir::new().expect("tempdir");
585
586        // User: same_table with field_a + field_b
587        create_table_dir(
588            &user_dir,
589            "same_table",
590            "  - name: field_a\n    type: string\n    required: false\n  - name: field_b\n    type: string\n    required: false\n",
591        );
592        // Project: same_table with only field_c
593        create_table_dir(
594            &project_dir,
595            "same_table",
596            "  - name: field_c\n    type: number\n    required: true\n",
597        );
598
599        let registry =
600            TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
601                .await
602                .expect("mount must succeed");
603
604        let entry = registry
605            .resolve(Some("same_table"))
606            .expect("same_table must exist");
607        // Only Project fields — User fields must not appear
608        assert_eq!(entry.schema.fields.len(), 1);
609        assert_eq!(entry.schema.fields[0].name, "field_c");
610    }
611
612    // T1: legacy env mode — 1 table + default_table set
613    #[tokio::test]
614    async fn legacy_mode_mounts_one_table_with_default() {
615        let dir = TempDir::new().expect("tempdir");
616        let schema_path = dir.path().join("schema.yaml");
617        let db_path = dir.path().join("legacy.db");
618
619        let yaml =
620            "table: legacy_table\nfields:\n  - name: title\n    type: string\n    required: true\n";
621        std::fs::write(&schema_path, yaml).expect("write schema.yaml");
622
623        let registry = TableRegistry::mount_legacy(&schema_path, &db_path)
624            .await
625            .expect("mount_legacy must succeed");
626
627        assert_eq!(registry.table_count(), 1);
628        assert_eq!(registry.default_table(), Some("legacy_table"));
629
630        // Resolving with None uses the default
631        let entry = registry
632            .resolve(None)
633            .expect("default resolve must succeed");
634        assert_eq!(entry.schema.table, "legacy_table");
635
636        // Resolving with explicit name also works
637        let entry2 = registry
638            .resolve(Some("legacy_table"))
639            .expect("explicit resolve must succeed");
640        assert_eq!(entry2.schema.table, "legacy_table");
641    }
642
643    // ── T2: boundary / edge-case tests ───────────────────────────────────
644
645    // T2: empty user_dir + empty project_dir → 0 tables mounted
646    #[tokio::test]
647    async fn empty_dirs_mount_zero_tables() {
648        let user_dir = TempDir::new().expect("tempdir");
649        let project_dir = TempDir::new().expect("tempdir");
650
651        let registry =
652            TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
653                .await
654                .expect("mount must not fail for empty dirs");
655
656        assert_eq!(registry.table_count(), 0);
657    }
658
659    // T2: both dirs are None → 0 tables, no error
660    #[tokio::test]
661    async fn both_dirs_none_mounts_zero_tables() {
662        let registry = TableRegistry::mount_from_dirs(None, None)
663            .await
664            .expect("mount must not fail when both dirs are None");
665
666        assert_eq!(registry.table_count(), 0);
667    }
668
669    // T2: non-existent dir is skipped with warn, not fatal
670    #[tokio::test]
671    async fn nonexistent_dir_is_skipped_not_fatal() {
672        let user_dir = TempDir::new().expect("tempdir");
673        create_table_dir(
674            &user_dir,
675            "table_a",
676            "  - name: f\n    type: string\n    required: false\n",
677        );
678
679        let nonexistent = PathBuf::from("/nonexistent/path/that/does/not/exist");
680        let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(&nonexistent))
681            .await
682            .expect("mount must succeed even when project_dir does not exist");
683
684        // Only table_a from user_dir should be mounted
685        assert_eq!(registry.table_count(), 1);
686        assert!(registry.resolve(Some("table_a")).is_ok());
687    }
688
689    // T2: subdir with no schema.yaml is skipped
690    #[tokio::test]
691    async fn subdir_without_schema_yaml_is_skipped() {
692        let user_dir = TempDir::new().expect("tempdir");
693        // Create a subdir but no schema.yaml
694        std::fs::create_dir(user_dir.path().join("no_schema")).expect("create dir");
695        // Also create a valid table
696        create_table_dir(
697            &user_dir,
698            "valid_table",
699            "  - name: f\n    type: string\n    required: false\n",
700        );
701
702        let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
703            .await
704            .expect("mount must succeed");
705
706        assert_eq!(registry.table_count(), 1);
707        assert!(registry.resolve(Some("valid_table")).is_ok());
708    }
709
710    // ── T3: error-path tests ─────────────────────────────────────────────
711
712    // T3: resolve with None and no default → TableRequired
713    #[tokio::test]
714    async fn resolve_none_without_default_returns_table_required() {
715        let user_dir = TempDir::new().expect("tempdir");
716        create_table_dir(
717            &user_dir,
718            "table_a",
719            "  - name: f\n    type: string\n    required: false\n",
720        );
721        create_table_dir(
722            &user_dir,
723            "table_b",
724            "  - name: g\n    type: string\n    required: false\n",
725        );
726
727        let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
728            .await
729            .expect("mount must succeed");
730
731        let result = registry.resolve(None);
732        assert!(
733            result.is_err(),
734            "resolve(None) must fail with no default table"
735        );
736        // SAFETY: we just asserted is_err() so this will not panic
737        if let Err(err) = result {
738            assert!(
739                matches!(err, MiniAppError::TableRequired),
740                "expected TableRequired, got: {err:?}"
741            );
742        }
743    }
744
745    // T3: resolve unknown table name → TableNotFound with correct table name
746    #[tokio::test]
747    async fn resolve_unknown_table_returns_table_not_found() {
748        let user_dir = TempDir::new().expect("tempdir");
749        create_table_dir(
750            &user_dir,
751            "table_a",
752            "  - name: f\n    type: string\n    required: false\n",
753        );
754
755        let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
756            .await
757            .expect("mount must succeed");
758
759        let result = registry.resolve(Some("nonexistent"));
760        assert!(result.is_err(), "resolve(nonexistent) must fail");
761        // SAFETY: we just asserted is_err() so this will not panic
762        if let Err(err) = result {
763            match err {
764                MiniAppError::TableNotFound { table } => {
765                    assert_eq!(table, "nonexistent");
766                }
767                other => panic!("expected TableNotFound, got: {other:?}"),
768            }
769        }
770    }
771
772    // (rmcp-dependent variant→McpError conversion tests live in
773    // `crates/mcp/src/error_conv.rs` to honor the one-way `mcp → core` dep
774    // boundary, Outline rust book §5-1-10 K-orphan-rule.)
775}