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}