Skip to main content

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}