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}