Skip to main content

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}