rustio_core/search/from_schema.rs
1//! Phase 14, commit 6 — bridge from `ModelSchema` to search.
2//!
3//! Schema-first: `ModelSchema` is the single source of truth for
4//! which attributes the engine tokenises, filters, and sorts. No
5//! manual `Searchable::SEARCHABLE_ATTRIBUTES` declaration is
6//! required when using this path.
7//!
8//! # What stays untouched
9//!
10//! - The existing `Searchable` trait (`search/traits.rs`) is
11//! **not** modified. Models that hand-implement `Searchable`
12//! keep working unchanged.
13//! - `MeiliClient`, `Indexer`, `client.rs`, `indexer.rs` are
14//! not modified — the bridge produces values that drop into
15//! their existing argument shapes (`configure_index(index,
16//! &searchable, &filterable, &sortable)`).
17//! - Nothing in `admin/`, `migrations`, `cli/`, `macros/`, or
18//! the contract / validator / doctor modules is touched.
19//!
20//! # Validator gate
21//!
22//! Search is enabled only when [`validate_schema`](crate::contract_validator::validate_schema)
23//! returns `Ok` or `Warning`:
24//!
25//! | Validator status | Bridge behaviour |
26//! |------------------|---------------------------------------|
27//! | `Ok` | enable search |
28//! | `Warning` | enable search (warnings logged) |
29//! | `Error` | refuse to enable — return diagnostics |
30//!
31//! The rationale: a schema that drifts from the DB will produce
32//! Meili documents with the wrong shape (missing fields, wrong
33//! types). Better to disable search loudly than to silently index
34//! garbage.
35//!
36//! # Mapping rules
37//!
38//! For each `ModelColumn`:
39//!
40//! | Column flag | Becomes part of |
41//! |---------------------|--------------------------------------|
42//! | `flags.searchable` | `searchable_attributes` |
43//! | `flags.filterable` | `filterable_attributes` |
44//! | `flags.sortable` | `sortable_attributes` |
45//!
46//! Plus:
47//!
48//! - `schema.search_index` → `SearchConfig.index`. `None` means
49//! "model isn't searchable", and the bridge returns
50//! [`SearchEnablement::NotSearchable`] without touching the
51//! validator.
52//! - `schema.primary_key` → `SearchConfig.primary_key`. Meili
53//! requires one unique key per document; the contract names it.
54//!
55//! # Order, exhaustiveness, no silent defaults
56//!
57//! - Output order matches `schema.columns` declaration order
58//! exactly. Reordering would silently change which fields a
59//! user-typed query weights highest in Meili.
60//! - No hardcoded field names. Every name flows from the schema.
61//! - Empty searchable set is allowed (and tested) — Meili treats
62//! an empty list as "search over all fields by default", which
63//! is its own answer to "no fields flagged"; the bridge honours
64//! the empty list rather than synthesising defaults.
65
66use std::sync::Arc;
67
68use crate::contract::{HasSchema, ModelSchema};
69use crate::contract_validator::{validate_schema, ReportStatus, SchemaReport};
70use crate::orm::Db;
71use crate::search::{Indexer, MeiliClient};
72
73// ---------------------------------------------------------------------------
74// SearchConfig
75// ---------------------------------------------------------------------------
76
77/// Search configuration derived from a `ModelSchema`. Designed
78/// to feed directly into [`MeiliClient::configure_index`] and
79/// [`Indexer`] without touching the existing `Searchable` trait.
80///
81/// All names are `&'static str` because they come from
82/// `ModelColumn`'s static-only fields (the contract is built at
83/// compile time by `#[derive(RustioModel)]`). No allocation on
84/// the lookup hot path.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct SearchConfig {
87 /// Meili index name. Sourced from `ModelSchema.search_index`.
88 pub index: &'static str,
89 /// Primary-key field on every document. Sourced from
90 /// `ModelSchema.primary_key`.
91 pub primary_key: &'static str,
92 /// Attributes Meili tokenises for full-text queries. Order
93 /// matches the schema's declaration order — Meili weights
94 /// the first attribute highest by default, so order matters.
95 pub searchable_attributes: Vec<&'static str>,
96 /// Attributes available for `filter=` queries.
97 pub filterable_attributes: Vec<&'static str>,
98 /// Attributes available for `sort=` queries.
99 pub sortable_attributes: Vec<&'static str>,
100}
101
102// ---------------------------------------------------------------------------
103// Pure derivation
104// ---------------------------------------------------------------------------
105
106/// Derive a `SearchConfig` from a schema **without** running the
107/// validator. Returns `None` when `schema.search_index` is `None`
108/// (the model isn't declared searchable in the contract).
109///
110/// Pure / synchronous. Safe to call from tests, build scripts,
111/// or any non-async context. Used by [`enable_search`] under the
112/// hood, and exposed publicly so callers that have already done
113/// their own validation can skip the gate.
114pub fn search_config_from_schema(schema: &ModelSchema) -> Option<SearchConfig> {
115 let index = schema.search_index?;
116 Some(SearchConfig {
117 index,
118 primary_key: schema.primary_key,
119 searchable_attributes: schema
120 .columns
121 .iter()
122 .filter(|c| c.flags.searchable)
123 .map(|c| c.name)
124 .collect(),
125 filterable_attributes: schema
126 .columns
127 .iter()
128 .filter(|c| c.flags.filterable)
129 .map(|c| c.name)
130 .collect(),
131 sortable_attributes: schema
132 .columns
133 .iter()
134 .filter(|c| c.flags.sortable)
135 .map(|c| c.name)
136 .collect(),
137 })
138}
139
140// ---------------------------------------------------------------------------
141// SearchEnablement — the validator-gated outcome
142// ---------------------------------------------------------------------------
143
144/// Result of asking "should search be enabled for this model?".
145///
146/// Three distinct outcomes so callers can log meaningfully:
147///
148/// - [`Self::NotSearchable`] — the contract declares no
149/// `search_index`. Not a failure; the model simply isn't
150/// indexed.
151/// - [`Self::Disabled`] — the validator returned errors. Search
152/// is refused; the report is included so operators can see why.
153/// - [`Self::Enabled`] — search is enabled. The config is ready
154/// to feed into Meili; the report is attached so any warnings
155/// can be logged.
156#[derive(Debug, Clone)]
157pub enum SearchEnablement {
158 NotSearchable,
159 Disabled { report: SchemaReport },
160 Enabled {
161 config: SearchConfig,
162 report: SchemaReport,
163 },
164}
165
166impl SearchEnablement {
167 /// Convenience: `true` iff search is enabled. Use when only
168 /// the gate decision matters (logging, metrics).
169 pub fn is_enabled(&self) -> bool {
170 matches!(self, SearchEnablement::Enabled { .. })
171 }
172
173 /// The derived [`SearchConfig`] when search is enabled.
174 /// `None` for `NotSearchable` and `Disabled`.
175 pub fn config(&self) -> Option<&SearchConfig> {
176 match self {
177 SearchEnablement::Enabled { config, .. } => Some(config),
178 _ => None,
179 }
180 }
181
182 /// The validator [`SchemaReport`], if one was produced. `None`
183 /// for `NotSearchable` (the gate short-circuits before
184 /// validating).
185 pub fn report(&self) -> Option<&SchemaReport> {
186 match self {
187 SearchEnablement::NotSearchable => None,
188 SearchEnablement::Disabled { report }
189 | SearchEnablement::Enabled { report, .. } => Some(report),
190 }
191 }
192}
193
194// ---------------------------------------------------------------------------
195// Validator-gated entry points
196// ---------------------------------------------------------------------------
197
198/// Ask the validator about `M`'s schema and return whether search
199/// should be enabled. The async boundary; production callers use
200/// this from server bootstrap.
201///
202/// Implementation note: this is a thin wrapper around
203/// [`validate_schema`] + [`enablement_from`]. Unit tests should
204/// target [`enablement_from`] directly to avoid needing a Postgres
205/// connection.
206pub async fn enable_search<M: HasSchema>(db: &Db) -> SearchEnablement {
207 let schema = M::SCHEMA;
208 let report = validate_schema::<M>(db).await;
209 enablement_from(&schema, report)
210}
211
212/// Pure decision helper splitting the validator-gated logic out
213/// of the async boundary. Given a schema and a (presumably already-
214/// produced) [`SchemaReport`], decide whether search should be
215/// enabled.
216///
217/// Three branches:
218///
219/// 1. `report.status == Error` → [`SearchEnablement::Disabled`].
220/// Refuse before deriving the config; the schema is broken,
221/// indexing it would silently produce malformed documents.
222/// 2. Schema has no `search_index` → [`SearchEnablement::NotSearchable`].
223/// The contract opted out of search; honour it.
224/// 3. Otherwise → [`SearchEnablement::Enabled`] with the config
225/// derived from the schema and the report attached for
226/// warning-level diagnostics.
227pub fn enablement_from(schema: &ModelSchema, report: SchemaReport) -> SearchEnablement {
228 match report.status {
229 ReportStatus::Error => SearchEnablement::Disabled { report },
230 ReportStatus::Ok | ReportStatus::Warning => match search_config_from_schema(schema) {
231 Some(config) => SearchEnablement::Enabled { config, report },
232 None => SearchEnablement::NotSearchable,
233 },
234 }
235}
236
237// ---------------------------------------------------------------------------
238// Phase 14, commit 8 — runtime indexer integration
239// ---------------------------------------------------------------------------
240
241/// Validator-gated indexer construction. Combines:
242///
243/// 1. [`enable_search::<T>`] — produce a [`SearchEnablement`]
244/// by validating the schema.
245/// 2. When `Enabled`: configure the Meili index's
246/// searchable / filterable / sortable attributes
247/// (`MeiliClient::configure_index`).
248/// 3. Spawn an [`Indexer`] backed by `client`.
249///
250/// Returns `None` when:
251/// - The validator returned errors (`Disabled`) — fail-safe;
252/// indexing against a drifted schema produces malformed
253/// documents.
254/// - The schema isn't searchable (`NotSearchable`) — the
255/// contract opted out.
256///
257/// Errors during the `configure_index` call are logged and
258/// treated as non-fatal: the indexer is still spawned (so
259/// pending documents can queue up while Meili is reachable
260/// later), but a return of `Some(_)` does not guarantee the
261/// index settings are current. Operators should monitor logs
262/// for `meili configure_index` failures.
263pub async fn indexer_from_schema<T: HasSchema>(
264 client: Arc<MeiliClient>,
265 db: &Db,
266 capacity: usize,
267) -> Option<Indexer> {
268 let outcome = enable_search::<T>(db).await;
269 match outcome {
270 SearchEnablement::Enabled { config, report } => {
271 // Capture warnings so operators see them once at
272 // startup; errors don't reach this branch.
273 for w in &report.warnings {
274 log::warn!(
275 "search: schema warning on `{}`: {}",
276 report.table, w.message
277 );
278 }
279 let searchable: Vec<&str> = config.searchable_attributes.to_vec();
280 let filterable: Vec<&str> = config.filterable_attributes.to_vec();
281 let sortable: Vec<&str> = config.sortable_attributes.to_vec();
282 if let Err(e) = client
283 .configure_index(config.index, &searchable, &filterable, &sortable)
284 .await
285 {
286 log::warn!(
287 "search: configure_index({}) failed at startup: {e} \
288 (indexer still spawned; documents will queue)",
289 config.index
290 );
291 } else {
292 log::info!(
293 "search: index `{}` configured (searchable={} filterable={} sortable={})",
294 config.index,
295 searchable.len(),
296 filterable.len(),
297 sortable.len()
298 );
299 }
300 Some(Indexer::spawn(client, capacity))
301 }
302 SearchEnablement::Disabled { report } => {
303 log::warn!(
304 "search: disabled for `{}` — validator reported {} error(s); \
305 indexer NOT spawned (fail-safe)",
306 report.table,
307 report.errors.len()
308 );
309 None
310 }
311 SearchEnablement::NotSearchable => None,
312 }
313}
314
315// ---------------------------------------------------------------------------
316// Tests
317// ---------------------------------------------------------------------------
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use crate::contract::{ModelColumn, RustType, SchemaFlags};
323 use crate::contract_validator::{IssueKind, SchemaIssue};
324
325 // ----- Fixture builders ------------------------------------------------
326
327 /// A schema with a mix of searchable / filterable / sortable
328 /// columns plus a non-flagged column. Drives the "only flagged
329 /// columns are indexed" and "ordering preserved" tests.
330 fn fixture_schema() -> ModelSchema {
331 static COLS: &[ModelColumn] = &[
332 // Primary key — sortable + readonly. Not searchable.
333 ModelColumn {
334 name: "id",
335 sql_decl: "BIGSERIAL PRIMARY KEY",
336 rust_type: RustType::I64,
337 nullable: false,
338 primary_key: true,
339 flags: SchemaFlags {
340 searchable: false,
341 filterable: false,
342 sortable: true,
343 readonly: true,
344 },
345 admin_label: None,
346 admin_widget: None,
347 },
348 // Searchable + filterable.
349 ModelColumn {
350 name: "title",
351 sql_decl: "TEXT NOT NULL",
352 rust_type: RustType::String,
353 nullable: false,
354 primary_key: false,
355 flags: SchemaFlags {
356 searchable: true,
357 filterable: true,
358 sortable: false,
359 readonly: false,
360 },
361 admin_label: None,
362 admin_widget: None,
363 },
364 // Searchable only — second searchable to verify order.
365 ModelColumn {
366 name: "body",
367 sql_decl: "TEXT",
368 rust_type: RustType::String,
369 nullable: true,
370 primary_key: false,
371 flags: SchemaFlags {
372 searchable: true,
373 filterable: false,
374 sortable: false,
375 readonly: false,
376 },
377 admin_label: None,
378 admin_widget: None,
379 },
380 // No flags — must be excluded from every list.
381 ModelColumn {
382 name: "internal_note",
383 sql_decl: "TEXT",
384 rust_type: RustType::String,
385 nullable: true,
386 primary_key: false,
387 flags: SchemaFlags::empty(),
388 admin_label: None,
389 admin_widget: None,
390 },
391 // Filterable + sortable, not searchable. Verifies the
392 // three lists are independent.
393 ModelColumn {
394 name: "published_at",
395 sql_decl: "TIMESTAMPTZ",
396 rust_type: RustType::DateTimeUtc,
397 nullable: true,
398 primary_key: false,
399 flags: SchemaFlags {
400 searchable: false,
401 filterable: true,
402 sortable: true,
403 readonly: false,
404 },
405 admin_label: None,
406 admin_widget: None,
407 },
408 ];
409 ModelSchema {
410 table: "posts",
411 columns: COLS,
412 primary_key: "id",
413 search_index: Some("posts"),
414 }
415 }
416
417 /// A schema with no `search_index` — the contract opts out of
418 /// search entirely. Drives the `NotSearchable` branch.
419 fn fixture_unsearchable_schema() -> ModelSchema {
420 static COLS: &[ModelColumn] = &[ModelColumn {
421 name: "id",
422 sql_decl: "BIGSERIAL PRIMARY KEY",
423 rust_type: RustType::I64,
424 nullable: false,
425 primary_key: true,
426 flags: SchemaFlags::empty(),
427 admin_label: None,
428 admin_widget: None,
429 }];
430 ModelSchema {
431 table: "audit_logs",
432 columns: COLS,
433 primary_key: "id",
434 search_index: None,
435 }
436 }
437
438 /// A schema with a `search_index` but zero columns flagged
439 /// `searchable` / `filterable` / `sortable`. The bridge must
440 /// honour the empty lists (no synthesised defaults).
441 fn fixture_empty_searchable_schema() -> ModelSchema {
442 static COLS: &[ModelColumn] = &[
443 ModelColumn {
444 name: "id",
445 sql_decl: "BIGSERIAL PRIMARY KEY",
446 rust_type: RustType::I64,
447 nullable: false,
448 primary_key: true,
449 flags: SchemaFlags::empty(),
450 admin_label: None,
451 admin_widget: None,
452 },
453 ModelColumn {
454 name: "value",
455 sql_decl: "TEXT NOT NULL",
456 rust_type: RustType::String,
457 nullable: false,
458 primary_key: false,
459 flags: SchemaFlags::empty(),
460 admin_label: None,
461 admin_widget: None,
462 },
463 ];
464 ModelSchema {
465 table: "items",
466 columns: COLS,
467 primary_key: "id",
468 search_index: Some("items"),
469 }
470 }
471
472 fn ok_report(table: &str) -> SchemaReport {
473 SchemaReport {
474 table: table.to_string(),
475 status: ReportStatus::Ok,
476 errors: vec![],
477 warnings: vec![],
478 }
479 }
480
481 fn warning_report(table: &str) -> SchemaReport {
482 SchemaReport {
483 table: table.to_string(),
484 status: ReportStatus::Warning,
485 errors: vec![],
486 warnings: vec![SchemaIssue {
487 column: Some("legacy_code".into()),
488 kind: IssueKind::ExtraDbColumn,
489 message: "extra DB column `legacy_code` not declared in Rust contract".into(),
490 expected: None,
491 actual: Some("legacy_code".into()),
492 }],
493 }
494 }
495
496 fn error_report(table: &str) -> SchemaReport {
497 SchemaReport {
498 table: table.to_string(),
499 status: ReportStatus::Error,
500 errors: vec![SchemaIssue {
501 column: Some("amount".into()),
502 kind: IssueKind::MissingColumn,
503 message: "column `posts.amount` declared in Rust contract not present in database"
504 .into(),
505 expected: Some("NUMERIC NOT NULL".into()),
506 actual: None,
507 }],
508 warnings: vec![],
509 }
510 }
511
512 // ----- Spec gate: searchable columns come ONLY from schema -------------
513
514 /// Spec gate: only fields with `flags.searchable == true` are
515 /// indexed. Verifies the bridge does not include any other
516 /// columns and does not invent any field names.
517 #[test]
518 fn searchable_attributes_drawn_only_from_flagged_columns() {
519 let schema = fixture_schema();
520 let cfg = search_config_from_schema(&schema).expect("schema is searchable");
521
522 // Only `title` and `body` are flagged searchable.
523 assert_eq!(cfg.searchable_attributes, vec!["title", "body"]);
524
525 // Negative: every other column stays out.
526 for excluded in ["id", "internal_note", "published_at"] {
527 assert!(
528 !cfg.searchable_attributes.contains(&excluded),
529 "column `{excluded}` should not appear in searchable_attributes"
530 );
531 }
532 }
533
534 /// Spec gate: non-searchable fields excluded. Sister assertion
535 /// to the previous test — frames the negative case directly.
536 #[test]
537 fn non_searchable_fields_excluded_from_search_list() {
538 let schema = fixture_schema();
539 let cfg = search_config_from_schema(&schema).unwrap();
540
541 // The `internal_note` column has all flags off — it must
542 // not appear in any list.
543 for list_name in [
544 ("searchable", &cfg.searchable_attributes),
545 ("filterable", &cfg.filterable_attributes),
546 ("sortable", &cfg.sortable_attributes),
547 ] {
548 let (name, list) = list_name;
549 assert!(
550 !list.contains(&"internal_note"),
551 "internal_note must be excluded from {name}"
552 );
553 }
554 }
555
556 // ----- Spec gate: ordering preserved ----------------------------------
557
558 /// Spec gate: order matches schema declaration order. Meili
559 /// weights the first searchable attribute highest, so a
560 /// stable order is part of the contract.
561 #[test]
562 fn ordering_preserved_within_searchable_attributes() {
563 let schema = fixture_schema();
564 let cfg = search_config_from_schema(&schema).unwrap();
565
566 // `title` comes before `body` in `schema.columns`.
567 let title_idx = cfg.searchable_attributes.iter().position(|s| *s == "title");
568 let body_idx = cfg.searchable_attributes.iter().position(|s| *s == "body");
569 assert_eq!(title_idx, Some(0));
570 assert_eq!(body_idx, Some(1));
571 }
572
573 /// `filterable_attributes` and `sortable_attributes` follow
574 /// the same order rule.
575 #[test]
576 fn ordering_preserved_within_filterable_and_sortable() {
577 let schema = fixture_schema();
578 let cfg = search_config_from_schema(&schema).unwrap();
579
580 // `title` (col idx 1) before `published_at` (col idx 4).
581 assert_eq!(cfg.filterable_attributes, vec!["title", "published_at"]);
582 // `id` (col idx 0) before `published_at` (col idx 4).
583 assert_eq!(cfg.sortable_attributes, vec!["id", "published_at"]);
584 }
585
586 // ----- Spec gate: empty searchable set handled safely -----------------
587
588 /// Spec gate: empty searchable set handled safely. A schema
589 /// that's nominally indexed but has zero flagged columns must
590 /// still produce a valid (empty-attribute-list) `SearchConfig`,
591 /// not panic, not synthesise defaults.
592 #[test]
593 fn empty_searchable_set_yields_empty_lists_not_panic() {
594 let schema = fixture_empty_searchable_schema();
595 let cfg = search_config_from_schema(&schema).expect("search_index is set");
596
597 assert_eq!(cfg.index, "items");
598 assert_eq!(cfg.primary_key, "id");
599 assert!(cfg.searchable_attributes.is_empty());
600 assert!(cfg.filterable_attributes.is_empty());
601 assert!(cfg.sortable_attributes.is_empty());
602 }
603
604 /// A schema that explicitly opts out of search (no
605 /// `search_index`) returns `None` from the pure derivation.
606 /// The validator gate treats this as `NotSearchable`.
607 #[test]
608 fn schema_with_no_search_index_yields_none() {
609 let schema = fixture_unsearchable_schema();
610 assert!(search_config_from_schema(&schema).is_none());
611 }
612
613 // ----- Spec gate: validator gating ------------------------------------
614
615 /// Spec gate: search disabled when validator returns errors.
616 /// The bridge refuses to enable search and surfaces the report
617 /// for diagnostics.
618 #[test]
619 fn search_disabled_when_validator_returns_errors() {
620 let schema = fixture_schema();
621 let report = error_report(schema.table);
622
623 let outcome = enablement_from(&schema, report.clone());
624 match outcome {
625 SearchEnablement::Disabled { report: r } => {
626 assert_eq!(r, report);
627 assert_eq!(r.status, ReportStatus::Error);
628 }
629 other => panic!("expected Disabled, got {:?}", other),
630 }
631
632 // is_enabled / config / report convenience methods agree.
633 let outcome = enablement_from(&schema, error_report(schema.table));
634 assert!(!outcome.is_enabled());
635 assert!(outcome.config().is_none());
636 assert!(outcome.report().is_some());
637 }
638
639 /// Spec gate: search allowed with warnings. A `Warning`-status
640 /// report is informational; the bridge still enables search.
641 #[test]
642 fn search_allowed_when_validator_returns_warnings_only() {
643 let schema = fixture_schema();
644 let report = warning_report(schema.table);
645
646 let outcome = enablement_from(&schema, report);
647 match outcome {
648 SearchEnablement::Enabled { config, report: r } => {
649 assert_eq!(r.status, ReportStatus::Warning);
650 assert_eq!(config.index, "posts");
651 assert_eq!(config.searchable_attributes, vec!["title", "body"]);
652 }
653 other => panic!("expected Enabled, got {:?}", other),
654 }
655 }
656
657 /// `Ok` status enables search with the report attached.
658 #[test]
659 fn search_enabled_when_validator_returns_ok() {
660 let schema = fixture_schema();
661 let outcome = enablement_from(&schema, ok_report(schema.table));
662 match outcome {
663 SearchEnablement::Enabled { config, report } => {
664 assert_eq!(report.status, ReportStatus::Ok);
665 assert_eq!(config.index, "posts");
666 assert_eq!(config.primary_key, "id");
667 assert_eq!(config.searchable_attributes, vec!["title", "body"]);
668 assert_eq!(config.filterable_attributes, vec!["title", "published_at"]);
669 assert_eq!(config.sortable_attributes, vec!["id", "published_at"]);
670 }
671 other => panic!("expected Enabled, got {:?}", other),
672 }
673 }
674
675 /// A schema without a `search_index` short-circuits to
676 /// `NotSearchable` regardless of the validator's verdict —
677 /// the contract opts out before validation matters.
678 #[test]
679 fn unsearchable_schema_short_circuits_to_not_searchable() {
680 let schema = fixture_unsearchable_schema();
681
682 // Even an `Ok` report doesn't enable search if the schema
683 // declares `search_index = None`.
684 let outcome = enablement_from(&schema, ok_report(schema.table));
685 match outcome {
686 SearchEnablement::NotSearchable => {}
687 other => panic!("expected NotSearchable, got {:?}", other),
688 }
689
690 // is_enabled / config / report convenience methods agree.
691 let outcome = enablement_from(&schema, ok_report(schema.table));
692 assert!(!outcome.is_enabled());
693 assert!(outcome.config().is_none());
694 assert!(outcome.report().is_none(), "NotSearchable carries no report");
695 }
696
697 // ----- SearchConfig invariants ----------------------------------------
698
699 /// Index name and primary key flow from schema verbatim. No
700 /// rewrites, no defaults.
701 #[test]
702 fn search_config_carries_schema_index_and_primary_key() {
703 let schema = fixture_schema();
704 let cfg = search_config_from_schema(&schema).unwrap();
705 assert_eq!(cfg.index, "posts");
706 assert_eq!(cfg.primary_key, "id");
707 }
708
709 /// Static slice usability: the produced lists are
710 /// `Vec<&'static str>`, so they can feed directly into Meili
711 /// API methods that take `&[&str]` without further allocation
712 /// of intermediate string buffers.
713 #[test]
714 fn search_config_lists_are_static_str_borrowable_as_str_slices() {
715 let schema = fixture_schema();
716 let cfg = search_config_from_schema(&schema).unwrap();
717 // Compile-time gate: this won't compile if the type
718 // changes from `Vec<&'static str>` to something else.
719 fn assert_static_strs(_: &[&'static str]) {}
720 assert_static_strs(&cfg.searchable_attributes);
721 assert_static_strs(&cfg.filterable_attributes);
722 assert_static_strs(&cfg.sortable_attributes);
723 }
724
725 // ----- Phase 14, commit 8 — runtime indexer integration -------------
726
727 /// `Indexer::from_schema` is exercised end-to-end by the
728 /// freelance example's runtime path; PG-gated tests cover
729 /// the live-DB branches. The pure / non-DB decision logic
730 /// is covered by `enablement_from` tests above. This test
731 /// pins the existence of the public symbol — a rename or
732 /// signature change fails to compile here rather than only
733 /// at downstream call sites.
734 #[test]
735 fn indexer_from_schema_symbol_visible() {
736 // Reference the function pointer to force symbol
737 // resolution. We never call it (would need a live DB
738 // + Meili); reaching this line proves the symbol is
739 // visible with the expected generic shape.
740 let _f = super::indexer_from_schema::<DummyHasSchema>;
741 }
742
743 // Stand-in `HasSchema` for the symbol-pinning test above.
744 struct DummyHasSchema;
745 impl crate::contract::HasSchema for DummyHasSchema {
746 const SCHEMA: ModelSchema = ModelSchema {
747 table: "dummy",
748 columns: &[],
749 primary_key: "id",
750 search_index: None,
751 };
752 }
753
754 /// `is_enabled` is the only branch that carries a config.
755 #[test]
756 fn enablement_accessor_invariants() {
757 let schema = fixture_schema();
758 let enabled = enablement_from(&schema, ok_report("posts"));
759 let disabled = enablement_from(&schema, error_report("posts"));
760 let none = enablement_from(&fixture_unsearchable_schema(), ok_report("audit_logs"));
761
762 assert!(enabled.is_enabled());
763 assert!(!disabled.is_enabled());
764 assert!(!none.is_enabled());
765
766 assert!(enabled.config().is_some());
767 assert!(disabled.config().is_none());
768 assert!(none.config().is_none());
769 }
770}