brows3r_lib/search/mod.rs
1//! Search module: types, cancellation, and the in-process search registry.
2//!
3//! # Sub-modules
4//!
5//! - `cancel` — `CancellationToken` (atomic flag wrapper).
6//!
7//! # Types exposed at module level
8//!
9//! - `EntryRef` — thin DTO for a single search result row.
10//! - `SearchPage` — one streamed page of search results.
11//! - `SearchRegistry` — tracks in-flight searches by `request_id`.
12//! - `SearchRegistryHandle` — `Arc<RwLock<SearchRegistry>>` for Tauri state.
13//!
14//! # OCP
15//!
16//! - `EntryRef` is a thin DTO — extending with metadata fields is non-breaking.
17//! - `SearchRegistry` operates on `request_id` strings; the registry is
18//! independent of search mode, so future modes (history search, tag search)
19//! register tokens the same way.
20
21pub mod cancel;
22
23use std::{collections::HashMap, sync::Arc};
24
25use serde::{Deserialize, Serialize};
26use tokio::sync::RwLock;
27
28use crate::search::cancel::CancellationToken;
29
30// ---------------------------------------------------------------------------
31// EntryRef — thin search result DTO
32// ---------------------------------------------------------------------------
33
34/// A single search result entry.
35///
36/// Intentionally thinner than `ObjectEntry` — only the fields needed to render
37/// a search result row are included. Extending with `etag` or `storage_class`
38/// is additive and non-breaking.
39///
40/// OCP: new metadata fields can be added here without changing the command or
41/// the event schema — serde skips unknown fields on the frontend.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct EntryRef {
45 /// Full S3 key (or common-prefix string for virtual folders).
46 pub key: String,
47 /// Object size in bytes. Always `0` for prefix entries.
48 pub size: u64,
49 /// Last-modified Unix timestamp in milliseconds. `None` for prefix entries.
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub last_modified: Option<i64>,
52 /// `true` when this entry represents a virtual-folder prefix.
53 pub is_prefix: bool,
54}
55
56// ---------------------------------------------------------------------------
57// SearchPage — one streamed page of results
58// ---------------------------------------------------------------------------
59
60/// One page of search results emitted as a `search:page` event.
61///
62/// The frontend accumulates pages in order until `is_final = true`.
63/// On cancellation the backend emits a final empty page with `is_final = true`
64/// so the frontend knows the stream is closed.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct SearchPage {
68 /// Echoed from the originating `search_prefix` call so the frontend can
69 /// match pages to the correct in-flight search even when multiple searches
70 /// overlap (e.g. the user typed fast).
71 pub request_id: String,
72 /// Zero-based page counter.
73 pub page_index: u32,
74 /// Matching entries for this page.
75 pub results: Vec<EntryRef>,
76 /// `true` on the very last page (either end-of-listing or cancelled).
77 pub is_final: bool,
78}
79
80// ---------------------------------------------------------------------------
81// SearchRegistry
82// ---------------------------------------------------------------------------
83
84/// In-memory registry of in-flight prefix searches.
85///
86/// Each search is identified by a `request_id` string. Registering a search
87/// creates a `CancellationToken`; cancelling it sets the token's flag. The
88/// background task polls `is_cancelled()` between pages and emits a final
89/// empty page before exiting.
90///
91/// OCP: the registry is mode-agnostic — future long-running commands can
92/// reuse the same pattern by holding their own token kind.
93#[derive(Default)]
94pub struct SearchRegistry {
95 tokens: HashMap<String, CancellationToken>,
96}
97
98impl SearchRegistry {
99 /// Register a new search and return a `CancellationToken` to pass to the
100 /// background task.
101 ///
102 /// If a search with the same `request_id` already exists, it is
103 /// overwritten (the old token is dropped, freeing any lingering Arc).
104 pub fn register(&mut self, request_id: impl Into<String>) -> CancellationToken {
105 let token = CancellationToken::new();
106 self.tokens.insert(request_id.into(), token.clone());
107 token
108 }
109
110 /// Cancel the search identified by `request_id`.
111 ///
112 /// Returns `true` when the token was found and cancelled; `false` when
113 /// no matching search exists (already completed or never started).
114 pub fn cancel(&mut self, request_id: &str) -> bool {
115 if let Some(token) = self.tokens.get(request_id) {
116 token.cancel();
117 true
118 } else {
119 false
120 }
121 }
122
123 /// Returns `true` when the search has been cancelled (or never started).
124 pub fn is_cancelled(&self, request_id: &str) -> bool {
125 self.tokens
126 .get(request_id)
127 .map_or(true, |t| t.is_cancelled())
128 }
129
130 /// Remove a completed (or cancelled) search from the registry.
131 ///
132 /// Call this after the background task emits its final page to free the
133 /// token from the HashMap. Idempotent — safe to call on missing ids.
134 pub fn remove(&mut self, request_id: &str) {
135 self.tokens.remove(request_id);
136 }
137}
138
139// ---------------------------------------------------------------------------
140// SearchRegistryHandle — Tauri managed state
141// ---------------------------------------------------------------------------
142
143/// `Arc<RwLock<SearchRegistry>>` used as Tauri managed state.
144///
145/// Commands receive `tauri::State<SearchRegistryHandle>`.
146#[derive(Clone, Default)]
147pub struct SearchRegistryHandle {
148 pub inner: Arc<RwLock<SearchRegistry>>,
149}
150
151impl SearchRegistryHandle {
152 pub fn new() -> Self {
153 Self::default()
154 }
155}
156
157// ---------------------------------------------------------------------------
158// Tests
159// ---------------------------------------------------------------------------
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 // -----------------------------------------------------------------------
166 // SearchRegistry
167 // -----------------------------------------------------------------------
168
169 #[test]
170 fn register_returns_uncancelled_token() {
171 let mut reg = SearchRegistry::default();
172 let token = reg.register("req-1");
173 assert!(!token.is_cancelled());
174 }
175
176 #[test]
177 fn cancel_known_request_returns_true() {
178 let mut reg = SearchRegistry::default();
179 reg.register("req-1");
180 assert!(reg.cancel("req-1"));
181 }
182
183 #[test]
184 fn cancel_unknown_request_returns_false() {
185 let mut reg = SearchRegistry::default();
186 assert!(!reg.cancel("does-not-exist"));
187 }
188
189 #[test]
190 fn cancel_makes_token_see_cancellation() {
191 let mut reg = SearchRegistry::default();
192 let token = reg.register("req-1");
193 reg.cancel("req-1");
194 assert!(
195 token.is_cancelled(),
196 "background task token must see cancellation"
197 );
198 }
199
200 #[test]
201 fn is_cancelled_true_after_cancel() {
202 let mut reg = SearchRegistry::default();
203 reg.register("req-1");
204 reg.cancel("req-1");
205 assert!(reg.is_cancelled("req-1"));
206 }
207
208 #[test]
209 fn is_cancelled_false_before_cancel() {
210 let mut reg = SearchRegistry::default();
211 reg.register("req-1");
212 assert!(!reg.is_cancelled("req-1"));
213 }
214
215 #[test]
216 fn is_cancelled_true_for_unknown_id() {
217 let reg = SearchRegistry::default();
218 // An unknown id is treated as "already cancelled / never started".
219 assert!(reg.is_cancelled("ghost"));
220 }
221
222 #[test]
223 fn remove_cleans_up_entry() {
224 let mut reg = SearchRegistry::default();
225 reg.register("req-1");
226 reg.remove("req-1");
227 // After removal, is_cancelled should return true (treated as unknown).
228 assert!(reg.is_cancelled("req-1"));
229 }
230
231 #[test]
232 fn overwrite_existing_request_id() {
233 let mut reg = SearchRegistry::default();
234 let _old = reg.register("req-1");
235 // Registering the same id again must succeed and return a fresh token.
236 let new_token = reg.register("req-1");
237 assert!(!new_token.is_cancelled());
238 }
239
240 // -----------------------------------------------------------------------
241 // SearchPage serialisation
242 // -----------------------------------------------------------------------
243
244 #[test]
245 fn search_page_serialises_correctly() {
246 let page = SearchPage {
247 request_id: "r-1".to_string(),
248 page_index: 0,
249 results: vec![EntryRef {
250 key: "folder/file.txt".to_string(),
251 size: 1024,
252 last_modified: Some(1_700_000_000_000),
253 is_prefix: false,
254 }],
255 is_final: false,
256 };
257 let v = serde_json::to_value(&page).unwrap();
258 assert_eq!(v["requestId"], "r-1");
259 assert_eq!(v["pageIndex"], 0);
260 assert_eq!(v["isFinal"], false);
261 assert_eq!(v["results"][0]["key"], "folder/file.txt");
262 assert_eq!(v["results"][0]["size"], 1024);
263 assert_eq!(v["results"][0]["isPrefix"], false);
264 }
265
266 #[test]
267 fn entry_ref_omits_none_last_modified() {
268 let entry = EntryRef {
269 key: "prefix/".to_string(),
270 size: 0,
271 last_modified: None,
272 is_prefix: true,
273 };
274 let v = serde_json::to_value(&entry).unwrap();
275 assert!(v.get("lastModified").is_none(), "None must be omitted");
276 }
277}