Skip to main content

coil_ops/
search.rs

1use crate::error::OpsModelError;
2use crate::identifiers::{SearchFieldId, SearchIndexId};
3use crate::validation::require_non_empty;
4use coil_auth::Capability;
5use coil_core::{
6    SearchFieldContribution as ManifestSearchFieldContribution,
7    SearchFieldRole as ManifestSearchFieldRole,
8    SearchInvalidationRule as ManifestSearchInvalidationRule,
9    SearchInvalidationTrigger as ManifestSearchInvalidationTrigger,
10};
11use std::collections::HashSet;
12use std::fmt;
13use std::time::Duration;
14
15mod catalog;
16mod mapping;
17
18pub use catalog::SearchCatalog;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum SearchDocumentKind {
22    Page,
23    Product,
24    Collection,
25    Event,
26    EventSlot,
27    Booking,
28    Media,
29    MembershipSubscription,
30    Custom,
31}
32
33impl fmt::Display for SearchDocumentKind {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            Self::Page => f.write_str("page"),
37            Self::Product => f.write_str("product"),
38            Self::Collection => f.write_str("collection"),
39            Self::Event => f.write_str("event"),
40            Self::EventSlot => f.write_str("event_slot"),
41            Self::Booking => f.write_str("booking"),
42            Self::Media => f.write_str("media"),
43            Self::MembershipSubscription => f.write_str("membership_subscription"),
44            Self::Custom => f.write_str("custom"),
45        }
46    }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum SearchFieldRole {
51    Title,
52    Summary,
53    Body,
54    Keyword,
55    Facet,
56    Metadata,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum SearchVisibility {
61    Public,
62    Authenticated,
63    Capability(Capability),
64}
65
66impl SearchVisibility {
67    pub fn allows(&self, capabilities: &[Capability]) -> bool {
68        match self {
69            Self::Public => true,
70            Self::Authenticated => !capabilities.is_empty(),
71            Self::Capability(capability) => capabilities.contains(capability),
72        }
73    }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum SearchInvalidationTrigger {
78    Published,
79    Updated,
80    Unpublished,
81    Deleted,
82    ManualRebuild,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum SearchRebuildStrategy {
87    OnInvalidate,
88    Scheduled { interval: Duration },
89    ManualOnly,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct SearchFieldContribution {
94    pub id: SearchFieldId,
95    pub source_path: String,
96    pub role: SearchFieldRole,
97    pub stored: bool,
98    pub searchable: bool,
99}
100
101impl SearchFieldContribution {
102    pub fn new(
103        id: SearchFieldId,
104        source_path: impl Into<String>,
105        role: SearchFieldRole,
106        stored: bool,
107        searchable: bool,
108    ) -> Result<Self, OpsModelError> {
109        Ok(Self {
110            id,
111            source_path: require_non_empty("search_field_source_path", source_path.into())?,
112            role,
113            stored,
114            searchable,
115        })
116    }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct SearchInvalidationRule {
121    pub trigger: SearchInvalidationTrigger,
122    pub reason: String,
123}
124
125impl SearchInvalidationRule {
126    pub fn new(
127        trigger: SearchInvalidationTrigger,
128        reason: impl Into<String>,
129    ) -> Result<Self, OpsModelError> {
130        Ok(Self {
131            trigger,
132            reason: require_non_empty("search_invalidation_reason", reason.into())?,
133        })
134    }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct SearchIndexContribution {
139    pub id: SearchIndexId,
140    pub source_module: String,
141    pub document_kind: SearchDocumentKind,
142    pub visibility: SearchVisibility,
143    pub publication_required: bool,
144    pub fields: Vec<SearchFieldContribution>,
145    pub invalidation_rules: Vec<SearchInvalidationRule>,
146    pub rebuild_strategy: SearchRebuildStrategy,
147}
148
149impl SearchIndexContribution {
150    pub fn new(
151        id: SearchIndexId,
152        source_module: impl Into<String>,
153        document_kind: SearchDocumentKind,
154        visibility: SearchVisibility,
155        publication_required: bool,
156        fields: Vec<SearchFieldContribution>,
157        invalidation_rules: Vec<SearchInvalidationRule>,
158        rebuild_strategy: SearchRebuildStrategy,
159    ) -> Result<Self, OpsModelError> {
160        let source_module = require_non_empty("search_source_module", source_module.into())?;
161
162        if fields.is_empty() {
163            return Err(OpsModelError::InvalidSearchVisibility {
164                index_id: id.to_string(),
165                reason: "at least one indexed field is required".to_string(),
166            });
167        }
168
169        if matches!(visibility, SearchVisibility::Public) && !publication_required {
170            return Err(OpsModelError::InvalidSearchVisibility {
171                index_id: id.to_string(),
172                reason: "public search indexes must require publication state".to_string(),
173            });
174        }
175
176        let mut seen_fields = HashSet::new();
177        for field in &fields {
178            if !seen_fields.insert(field.id.as_str().to_string()) {
179                return Err(OpsModelError::DuplicateField {
180                    index_id: id.to_string(),
181                    field_id: field.id.to_string(),
182                });
183            }
184        }
185
186        Ok(Self {
187            id,
188            source_module,
189            document_kind,
190            visibility,
191            publication_required,
192            fields,
193            invalidation_rules,
194            rebuild_strategy,
195        })
196    }
197
198    pub fn visible_to(&self, capabilities: &[Capability]) -> bool {
199        self.visibility.allows(capabilities)
200    }
201}