Skip to main content

subcog/models/
search.rs

1//! Search types and filters.
2
3use super::{Domain, Memory, MemoryStatus, Namespace};
4use std::fmt;
5
6/// Search mode for memory recall.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum SearchMode {
9    /// Vector similarity search only.
10    Vector,
11    /// BM25 text search only.
12    Text,
13    /// Hybrid search with RRF fusion (default).
14    #[default]
15    Hybrid,
16}
17
18/// Level of detail to include in search results.
19///
20/// Controls response size and token usage.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum DetailLevel {
23    /// Frontmatter only: id, namespace, domain, tags, score.
24    /// No content included.
25    Light,
26    /// Frontmatter + summary: truncated content (~200 chars).
27    #[default]
28    Medium,
29    /// Full memory content and all metadata.
30    Everything,
31}
32
33impl DetailLevel {
34    /// Returns the level as a string slice.
35    #[must_use]
36    pub const fn as_str(&self) -> &'static str {
37        match self {
38            Self::Light => "light",
39            Self::Medium => "medium",
40            Self::Everything => "everything",
41        }
42    }
43
44    /// Parses a detail level from a string.
45    #[must_use]
46    pub fn parse(s: &str) -> Option<Self> {
47        match s.to_lowercase().as_str() {
48            "light" | "minimal" | "frontmatter" => Some(Self::Light),
49            "medium" | "summary" | "default" => Some(Self::Medium),
50            "everything" | "full" | "all" => Some(Self::Everything),
51            _ => None,
52        }
53    }
54
55    /// Returns the content truncation length for this level.
56    #[must_use]
57    pub const fn content_length(&self) -> Option<usize> {
58        match self {
59            Self::Light => Some(0),    // No content
60            Self::Medium => Some(200), // Summary
61            Self::Everything => None,  // Full content
62        }
63    }
64}
65
66impl fmt::Display for DetailLevel {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        write!(f, "{}", self.as_str())
69    }
70}
71
72impl SearchMode {
73    /// Returns the mode as a string slice.
74    #[must_use]
75    pub const fn as_str(&self) -> &'static str {
76        match self {
77            Self::Vector => "vector",
78            Self::Text => "text",
79            Self::Hybrid => "hybrid",
80        }
81    }
82}
83
84impl fmt::Display for SearchMode {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        write!(f, "{}", self.as_str())
87    }
88}
89
90/// Filter criteria for memory search.
91#[derive(Debug, Clone, Default)]
92pub struct SearchFilter {
93    /// Filter by namespaces.
94    pub namespaces: Vec<Namespace>,
95    /// Filter by domains.
96    pub domains: Vec<Domain>,
97    /// Filter by statuses.
98    pub statuses: Vec<MemoryStatus>,
99    /// Filter by tags (AND logic - must have ALL).
100    pub tags: Vec<String>,
101    /// Filter by tags (OR logic - must have ANY).
102    pub tags_any: Vec<String>,
103    /// Exclude memories with these tags.
104    pub excluded_tags: Vec<String>,
105    /// Filter by source pattern (glob-style).
106    pub source_pattern: Option<String>,
107    /// Filter by project identifier (normalized git remote URL).
108    pub project_id: Option<String>,
109    /// Filter by branch name.
110    pub branch: Option<String>,
111    /// Filter by file path (relative to repo root).
112    pub file_path: Option<String>,
113    /// Minimum creation timestamp.
114    pub created_after: Option<u64>,
115    /// Maximum creation timestamp.
116    pub created_before: Option<u64>,
117    /// Minimum similarity score (0.0 to 1.0).
118    pub min_score: Option<f32>,
119    /// Include tombstoned memories (default: false).
120    pub include_tombstoned: bool,
121    /// Filter by entity names (memories mentioning these entities).
122    /// Uses OR logic - matches memories mentioning ANY of the listed entities.
123    pub entity_names: Vec<String>,
124    /// Filter by group identifiers (group-scoped memories).
125    /// Uses OR logic - matches memories in ANY of the listed groups.
126    #[cfg(feature = "group-scope")]
127    pub group_ids: Vec<String>,
128}
129
130impl SearchFilter {
131    /// Creates an empty filter (matches all).
132    #[must_use]
133    pub const fn new() -> Self {
134        Self {
135            namespaces: Vec::new(),
136            domains: Vec::new(),
137            statuses: Vec::new(),
138            tags: Vec::new(),
139            tags_any: Vec::new(),
140            excluded_tags: Vec::new(),
141            source_pattern: None,
142            project_id: None,
143            branch: None,
144            file_path: None,
145            created_after: None,
146            created_before: None,
147            min_score: None,
148            include_tombstoned: false,
149            entity_names: Vec::new(),
150            #[cfg(feature = "group-scope")]
151            group_ids: Vec::new(),
152        }
153    }
154
155    /// Adds a namespace filter.
156    #[must_use]
157    pub fn with_namespace(mut self, namespace: Namespace) -> Self {
158        self.namespaces.push(namespace);
159        self
160    }
161
162    /// Adds a domain filter.
163    #[must_use]
164    pub fn with_domain(mut self, domain: Domain) -> Self {
165        self.domains.push(domain);
166        self
167    }
168
169    /// Adds a status filter.
170    #[must_use]
171    pub fn with_status(mut self, status: MemoryStatus) -> Self {
172        self.statuses.push(status);
173        self
174    }
175
176    /// Adds a tag filter (AND logic - must have ALL).
177    #[must_use]
178    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
179        self.tags.push(tag.into());
180        self
181    }
182
183    /// Adds a tag filter (OR logic - must have ANY).
184    #[must_use]
185    pub fn with_tag_any(mut self, tag: impl Into<String>) -> Self {
186        self.tags_any.push(tag.into());
187        self
188    }
189
190    /// Adds an excluded tag filter.
191    #[must_use]
192    pub fn with_excluded_tag(mut self, tag: impl Into<String>) -> Self {
193        self.excluded_tags.push(tag.into());
194        self
195    }
196
197    /// Sets the source pattern filter (glob-style).
198    #[must_use]
199    pub fn with_source_pattern(mut self, pattern: impl Into<String>) -> Self {
200        self.source_pattern = Some(pattern.into());
201        self
202    }
203
204    /// Sets the project identifier filter.
205    #[must_use]
206    pub fn with_project_id(mut self, project_id: impl Into<String>) -> Self {
207        self.project_id = Some(project_id.into());
208        self
209    }
210
211    /// Sets the branch filter.
212    #[must_use]
213    pub fn with_branch(mut self, branch: impl Into<String>) -> Self {
214        self.branch = Some(branch.into());
215        self
216    }
217
218    /// Sets the file path filter.
219    #[must_use]
220    pub fn with_file_path(mut self, file_path: impl Into<String>) -> Self {
221        self.file_path = Some(file_path.into());
222        self
223    }
224
225    /// Sets the minimum score threshold.
226    #[must_use]
227    pub const fn with_min_score(mut self, score: f32) -> Self {
228        self.min_score = Some(score);
229        self
230    }
231
232    /// Sets the `created_after` filter.
233    #[must_use]
234    pub const fn with_created_after(mut self, timestamp: u64) -> Self {
235        self.created_after = Some(timestamp);
236        self
237    }
238
239    /// Sets the `created_before` filter.
240    #[must_use]
241    pub const fn with_created_before(mut self, timestamp: u64) -> Self {
242        self.created_before = Some(timestamp);
243        self
244    }
245
246    /// Includes tombstoned memories in results.
247    #[must_use]
248    pub const fn with_include_tombstoned(mut self, include: bool) -> Self {
249        self.include_tombstoned = include;
250        self
251    }
252
253    /// Returns true if the filter is empty (matches all).
254    #[must_use]
255    #[allow(clippy::missing_const_for_fn)] // Can't be const due to cfg attributes
256    pub fn is_empty(&self) -> bool {
257        let base_empty = self.namespaces.is_empty()
258            && self.domains.is_empty()
259            && self.statuses.is_empty()
260            && self.tags.is_empty()
261            && self.tags_any.is_empty()
262            && self.excluded_tags.is_empty()
263            && self.source_pattern.is_none()
264            && self.project_id.is_none()
265            && self.branch.is_none()
266            && self.file_path.is_none()
267            && self.created_after.is_none()
268            && self.created_before.is_none()
269            && self.min_score.is_none()
270            && self.entity_names.is_empty();
271
272        #[cfg(feature = "group-scope")]
273        {
274            base_empty && self.group_ids.is_empty()
275        }
276        #[cfg(not(feature = "group-scope"))]
277        {
278            base_empty
279        }
280    }
281
282    /// Adds an entity name filter.
283    /// Filters to memories mentioning this entity (OR logic with other entities).
284    #[must_use]
285    pub fn with_entity(mut self, entity: impl Into<String>) -> Self {
286        self.entity_names.push(entity.into());
287        self
288    }
289
290    /// Adds multiple entity name filters.
291    #[must_use]
292    pub fn with_entities(mut self, entities: impl IntoIterator<Item = impl Into<String>>) -> Self {
293        self.entity_names
294            .extend(entities.into_iter().map(Into::into));
295        self
296    }
297
298    /// Adds a group identifier filter.
299    ///
300    /// Filters to memories belonging to this group (OR logic with other groups).
301    #[cfg(feature = "group-scope")]
302    #[must_use]
303    pub fn with_group_id(mut self, group_id: impl Into<String>) -> Self {
304        self.group_ids.push(group_id.into());
305        self
306    }
307
308    /// Adds multiple group identifier filters.
309    #[cfg(feature = "group-scope")]
310    #[must_use]
311    pub fn with_group_ids(
312        mut self,
313        group_ids: impl IntoIterator<Item = impl Into<String>>,
314    ) -> Self {
315        self.group_ids.extend(group_ids.into_iter().map(Into::into));
316        self
317    }
318}
319
320/// Result of a memory search.
321#[derive(Debug, Clone)]
322pub struct SearchResult {
323    /// The matching memories.
324    pub memories: Vec<SearchHit>,
325    /// Total count of matches (may be more than returned).
326    pub total_count: usize,
327    /// The search mode used.
328    pub mode: SearchMode,
329    /// Search execution time in milliseconds.
330    pub execution_time_ms: u64,
331}
332
333/// A single search hit with scoring.
334#[derive(Debug, Clone)]
335pub struct SearchHit {
336    /// The matched memory.
337    pub memory: Memory,
338    /// Normalized combined score (0.0 to 1.0).
339    /// This is the primary score for display to users.
340    /// The max score in a result set is always 1.0.
341    pub score: f32,
342    /// Raw combined score before normalization.
343    /// Useful for debugging RRF fusion behavior.
344    /// This is the sum of RRF contributions from text and vector search.
345    pub raw_score: f32,
346    /// Vector similarity score if applicable.
347    pub vector_score: Option<f32>,
348    /// BM25 text score if applicable.
349    pub bm25_score: Option<f32>,
350}