Skip to main content

stateset_core/models/
search_config.rs

1//! Search configuration domain models
2//!
3//! Defines searchable field configuration, facets, synonyms, and boost rules
4//! for product search customization.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use stateset_primitives::SearchConfigId;
9use strum::{Display, EnumString};
10
11/// Tokenizer strategy for a searchable field
12#[derive(
13    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
14)]
15#[serde(rename_all = "snake_case")]
16#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
17#[non_exhaustive]
18pub enum Tokenizer {
19    /// Standard word-boundary tokenizer
20    #[default]
21    Standard,
22    /// N-gram tokenizer (for substring matching)
23    Ngram,
24    /// Edge n-gram tokenizer (for autocomplete)
25    Edge,
26    /// Keyword tokenizer (exact match only)
27    Keyword,
28}
29
30/// Facet type for search refinement
31#[derive(
32    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
33)]
34#[serde(rename_all = "snake_case")]
35#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
36#[non_exhaustive]
37pub enum FacetType {
38    /// Distinct value facet (e.g., brand, color)
39    #[default]
40    Value,
41    /// Numeric range facet (e.g., price ranges)
42    Range,
43    /// Hierarchical facet (e.g., category tree)
44    Hierarchical,
45}
46
47/// A search configuration
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SearchConfig {
50    /// Unique config ID
51    pub id: SearchConfigId,
52    /// Configuration name
53    pub name: String,
54    /// Description
55    pub description: Option<String>,
56    /// Fields available for search
57    pub searchable_fields: Vec<SearchField>,
58    /// Facet definitions for filtering
59    pub facets: Vec<FacetConfig>,
60    /// Synonym groups
61    pub synonyms: Vec<SynonymGroup>,
62    /// Boost rules for relevance tuning
63    pub boost_rules: Vec<BoostRule>,
64    /// Whether this is the active configuration
65    pub is_active: bool,
66    /// When the config was created
67    pub created_at: DateTime<Utc>,
68    /// When the config was last updated
69    pub updated_at: DateTime<Utc>,
70}
71
72/// A searchable field configuration
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SearchField {
75    /// Field name (e.g., "title", "description", "sku")
76    pub field_name: String,
77    /// Relative search weight (higher = more important)
78    pub weight: f64,
79    /// Tokenizer to use for this field
80    pub tokenizer: Tokenizer,
81    /// Whether this field is enabled for search
82    pub enabled: bool,
83}
84
85/// Facet configuration for search filtering
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct FacetConfig {
88    /// Field to facet on
89    pub field_name: String,
90    /// Facet type
91    pub facet_type: FacetType,
92    /// Display name in UI
93    pub display_name: String,
94    /// Sort order in the facet panel
95    pub sort_order: i32,
96    /// Maximum number of facet values to return
97    pub max_values: Option<u32>,
98}
99
100/// A group of synonymous terms
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct SynonymGroup {
103    /// The canonical/primary term
104    pub canonical: String,
105    /// Synonymous terms that should match the canonical
106    pub synonyms: Vec<String>,
107}
108
109/// A boost rule for search relevance tuning
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct BoostRule {
112    /// Field to apply the boost to
113    pub field: String,
114    /// Value pattern to match (exact or wildcard)
115    pub value_match: String,
116    /// Boost factor (> 1.0 increases relevance, < 1.0 decreases)
117    pub boost_factor: f64,
118}
119
120/// Input for creating a search configuration
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct CreateSearchConfig {
123    /// Configuration name
124    pub name: String,
125    /// Description
126    pub description: Option<String>,
127    /// Searchable fields
128    pub searchable_fields: Vec<SearchField>,
129    /// Facets
130    pub facets: Vec<FacetConfig>,
131    /// Synonyms
132    pub synonyms: Vec<SynonymGroup>,
133    /// Boost rules
134    pub boost_rules: Vec<BoostRule>,
135}
136
137/// Input for updating a search configuration
138#[derive(Debug, Clone, Serialize, Deserialize, Default)]
139pub struct UpdateSearchConfig {
140    /// Updated name
141    pub name: Option<String>,
142    /// Updated description
143    pub description: Option<Option<String>>,
144    /// Updated searchable fields (replaces all)
145    pub searchable_fields: Option<Vec<SearchField>>,
146    /// Updated facets (replaces all)
147    pub facets: Option<Vec<FacetConfig>>,
148    /// Updated synonyms (replaces all)
149    pub synonyms: Option<Vec<SynonymGroup>>,
150    /// Updated boost rules (replaces all)
151    pub boost_rules: Option<Vec<BoostRule>>,
152    /// Updated active status
153    pub is_active: Option<bool>,
154}
155
156/// Filter for listing search configurations
157#[derive(Debug, Clone, Serialize, Deserialize, Default)]
158pub struct SearchConfigFilter {
159    /// Filter by active status
160    pub is_active: Option<bool>,
161    /// Search by name
162    pub name: Option<String>,
163    /// Maximum results
164    pub limit: Option<u32>,
165    /// Offset for pagination
166    pub offset: Option<u32>,
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    // ---- Defaults ----
174
175    #[test]
176    fn tokenizer_default_is_standard() {
177        assert_eq!(Tokenizer::default(), Tokenizer::Standard);
178    }
179
180    #[test]
181    fn facet_type_default_is_value() {
182        assert_eq!(FacetType::default(), FacetType::Value);
183    }
184
185    // ---- enum Display / FromStr round-trips ----
186
187    #[test]
188    fn tokenizer_display_fromstr_roundtrip() {
189        for tokenizer in
190            [Tokenizer::Standard, Tokenizer::Ngram, Tokenizer::Edge, Tokenizer::Keyword]
191        {
192            let s = tokenizer.to_string();
193            let parsed: Tokenizer = s.parse().unwrap();
194            assert_eq!(parsed, tokenizer, "round-trip failed for {s}");
195        }
196    }
197
198    #[test]
199    fn facet_type_display_fromstr_roundtrip() {
200        for facet_type in [FacetType::Value, FacetType::Range, FacetType::Hierarchical] {
201            let s = facet_type.to_string();
202            let parsed: FacetType = s.parse().unwrap();
203            assert_eq!(parsed, facet_type, "round-trip failed for {s}");
204        }
205    }
206
207    // ---- basic struct construction ----
208
209    #[test]
210    fn search_field_construction() {
211        let field = SearchField {
212            field_name: "title".to_string(),
213            weight: 2.0,
214            tokenizer: Tokenizer::Standard,
215            enabled: true,
216        };
217        assert_eq!(field.field_name, "title");
218        assert!(field.enabled);
219    }
220
221    #[test]
222    fn synonym_group_construction() {
223        let group = SynonymGroup {
224            canonical: "shirt".to_string(),
225            synonyms: vec!["tee".to_string(), "top".to_string()],
226        };
227        assert_eq!(group.synonyms.len(), 2);
228    }
229}