Skip to main content

brows3r_lib/commands/
search_cmd.rs

1//! Tauri commands for search operations.
2//!
3//! # Commands
4//!
5//! - [`search_local_filter`]  — pure in-process filter over a caller-supplied
6//!                              `Vec<EntryRef>` slice; no S3 calls.
7//! - [`search_prefix`]        — paginated bucket-wide search that emits
8//!                              `search:page` events; returns `request_id`.
9//! - [`search_cancel`]        — cancel an in-flight prefix search.
10//!
11//! # OCP
12//!
13//! Search modes are independent command paths.  Adding a new mode
14//! (e.g. history search) is one new command here.  `CancellationToken` and
15//! `SearchRegistry` are reusable for any future long-running command.
16
17use tauri::{AppHandle, State};
18
19use crate::{
20    error::AppError,
21    events::{self, EventKind},
22    ids::{BucketId, ProfileId},
23    profiles::ProfileStoreHandle,
24    s3::{list::list_objects_flat, S3ClientPoolHandle},
25    search::{EntryRef, SearchPage, SearchRegistryHandle},
26    settings::SettingsHandle,
27};
28
29// ---------------------------------------------------------------------------
30// search_local_filter
31// ---------------------------------------------------------------------------
32
33/// Filter `entries` by `query` (case-insensitive substring match on `key`).
34///
35/// This is a pure, synchronous operation — no IPC, no S3.  The frontend
36/// sends its current cached listing slice and gets back the matching subset.
37///
38/// An empty `query` returns all entries unchanged.
39#[tauri::command]
40pub async fn search_local_filter(
41    _pane_id: String,
42    query: String,
43    entries: Vec<EntryRef>,
44) -> Result<Vec<EntryRef>, AppError> {
45    if query.is_empty() {
46        return Ok(entries);
47    }
48
49    let q = query.to_lowercase();
50    let results = entries
51        .into_iter()
52        .filter(|e| e.key.to_lowercase().contains(&q))
53        .collect();
54
55    Ok(results)
56}
57
58// ---------------------------------------------------------------------------
59// search_prefix
60// ---------------------------------------------------------------------------
61
62/// Begin a paginated, cancellable prefix search.
63///
64/// Returns `request_id` immediately.  A background tokio task walks
65/// `ListObjectsV2` pages starting at `prefix`, filtering each page by `query`
66/// (case-insensitive substring on the key relative to `prefix`), and emits a
67/// `search:page` event for each batch of matching results.
68///
69/// Walk concurrency is capped by the `transfer_concurrency` setting to avoid
70/// monopolising the S3 connection pool.
71///
72/// # Cancellation
73///
74/// Call `search_cancel(request_id)` to stop the walk.  The background task
75/// checks the cancellation token between pages; on cancellation it emits a
76/// final empty page with `is_final = true` and exits.
77#[tauri::command]
78pub async fn search_prefix(
79    profile_id: ProfileId,
80    bucket: BucketId,
81    prefix: String,
82    query: String,
83    request_id: String,
84    store: State<'_, ProfileStoreHandle>,
85    pool: State<'_, S3ClientPoolHandle>,
86    search_registry: State<'_, SearchRegistryHandle>,
87    settings: State<'_, SettingsHandle>,
88    channel: AppHandle,
89) -> Result<String, AppError> {
90    // ------------------------------------------------------------------
91    // 1. Resolve profile + validation gate
92    // ------------------------------------------------------------------
93    let profile = {
94        let store_guard = store.inner.lock().await;
95        store_guard
96            .get(&profile_id)
97            .ok_or_else(|| AppError::NotFound {
98                resource: format!("profile:{}", profile_id.as_str()),
99            })?
100    };
101
102    if profile.validated_at.is_none() {
103        return Err(AppError::Auth {
104            reason: "profile_not_validated_in_session".to_string(),
105        });
106    }
107
108    let default_region = profile
109        .default_region
110        .clone()
111        .unwrap_or_else(|| "us-east-1".to_string());
112
113    // ------------------------------------------------------------------
114    // 2. Build S3 client
115    // ------------------------------------------------------------------
116    let client = pool
117        .inner
118        .get_or_build(&profile_id, &default_region)
119        .await
120        .ok_or_else(|| AppError::Internal {
121            trace_id: format!("pool_miss:profile:{}", profile_id.as_str()),
122        })?;
123
124    // ------------------------------------------------------------------
125    // 3. Register cancellation token
126    // ------------------------------------------------------------------
127    let token = {
128        let mut reg = search_registry.inner.write().await;
129        reg.register(request_id.clone())
130    };
131
132    // ------------------------------------------------------------------
133    // 4. Read settings for concurrency cap (informational — we honour it
134    //    by limiting to one page-fetch at a time in the loop below).
135    // ------------------------------------------------------------------
136    let _transfer_concurrency = {
137        let s = settings.inner.lock().await;
138        s.transfer_concurrency
139    };
140
141    // ------------------------------------------------------------------
142    // 5. Spawn background walk task
143    // ------------------------------------------------------------------
144    let rid = request_id.clone();
145    let bucket_str = bucket.as_str().to_string();
146    let prefix_clone = prefix.clone();
147    let query_clone = query.clone();
148    let registry_handle = search_registry.inner.clone();
149
150    tokio::spawn(async move {
151        let mut continuation_token: Option<String> = None;
152        let mut page_index: u32 = 0;
153        let q = query_clone.to_lowercase();
154
155        loop {
156            // Check cancellation before each page fetch.
157            if token.is_cancelled() {
158                // Emit a final empty page to close the stream cleanly.
159                let final_page = SearchPage {
160                    request_id: rid.clone(),
161                    page_index,
162                    results: vec![],
163                    is_final: true,
164                };
165                let _ = events::emit(&channel, EventKind::SearchPage, &final_page);
166                break;
167            }
168
169            let ct_ref = continuation_token.as_deref();
170
171            let list_result =
172                list_objects_flat(&client, &bucket_str, &prefix_clone, ct_ref, Some(1000)).await;
173
174            let page = match list_result {
175                Ok(p) => p,
176                Err(_) => {
177                    // On error emit a final empty page so the frontend doesn't hang.
178                    let final_page = SearchPage {
179                        request_id: rid.clone(),
180                        page_index,
181                        results: vec![],
182                        is_final: true,
183                    };
184                    let _ = events::emit(&channel, EventKind::SearchPage, &final_page);
185                    break;
186                }
187            };
188
189            let is_last_page = !page.is_truncated || page.next_continuation_token.is_none();
190
191            // Filter matching entries.
192            let results: Vec<EntryRef> = page
193                .entries
194                .iter()
195                .filter(|e| q.is_empty() || e.key.to_lowercase().contains(&q))
196                .map(|e| EntryRef {
197                    key: e.key.clone(),
198                    size: e.size,
199                    last_modified: e.last_modified,
200                    is_prefix: e.is_prefix,
201                })
202                .collect();
203
204            let search_page = SearchPage {
205                request_id: rid.clone(),
206                page_index,
207                results,
208                is_final: is_last_page,
209            };
210
211            let _ = events::emit(&channel, EventKind::SearchPage, &search_page);
212
213            if is_last_page {
214                break;
215            }
216
217            continuation_token = page.next_continuation_token;
218            page_index += 1;
219        }
220
221        // Clean up the registry entry so it doesn't grow unbounded.
222        let mut reg = registry_handle.write().await;
223        reg.remove(&rid);
224    });
225
226    Ok(request_id)
227}
228
229// ---------------------------------------------------------------------------
230// search_cancel
231// ---------------------------------------------------------------------------
232
233/// Cancel an in-flight prefix search identified by `request_id`.
234///
235/// The background task will detect the cancellation between pages and emit a
236/// final empty `search:page` event before exiting.
237///
238/// Returns `Ok(())` even when the `request_id` is not found (already completed
239/// or never started) — this is intentional: the frontend may race and call
240/// cancel after the search already finished.
241#[tauri::command]
242pub async fn search_cancel(
243    request_id: String,
244    search_registry: State<'_, SearchRegistryHandle>,
245) -> Result<(), AppError> {
246    let mut reg = search_registry.inner.write().await;
247    reg.cancel(&request_id);
248    Ok(())
249}
250
251// ---------------------------------------------------------------------------
252// Tests
253// ---------------------------------------------------------------------------
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::events::{EventKind, MockChannel};
259    use crate::search::{EntryRef, SearchPage, SearchRegistry};
260
261    // -----------------------------------------------------------------------
262    // search_local_filter
263    // -----------------------------------------------------------------------
264
265    #[tokio::test]
266    async fn local_filter_case_insensitive_substring() {
267        let entries = vec![
268            EntryRef {
269                key: "photos/Image.jpg".to_string(),
270                size: 100,
271                last_modified: None,
272                is_prefix: false,
273            },
274            EntryRef {
275                key: "docs/report.pdf".to_string(),
276                size: 200,
277                last_modified: None,
278                is_prefix: false,
279            },
280            EntryRef {
281                key: "photos/thumbnail.png".to_string(),
282                size: 50,
283                last_modified: None,
284                is_prefix: false,
285            },
286        ];
287        let result = search_local_filter("pane-1".to_string(), "IMAGE".to_string(), entries)
288            .await
289            .unwrap();
290
291        assert_eq!(result.len(), 1);
292        assert_eq!(result[0].key, "photos/Image.jpg");
293    }
294
295    #[tokio::test]
296    async fn local_filter_empty_query_returns_all() {
297        let entries = vec![
298            EntryRef {
299                key: "a.txt".to_string(),
300                size: 1,
301                last_modified: None,
302                is_prefix: false,
303            },
304            EntryRef {
305                key: "b.txt".to_string(),
306                size: 2,
307                last_modified: None,
308                is_prefix: false,
309            },
310        ];
311        let count = entries.len();
312        let result = search_local_filter("pane-1".to_string(), String::new(), entries)
313            .await
314            .unwrap();
315
316        assert_eq!(result.len(), count);
317    }
318
319    #[tokio::test]
320    async fn local_filter_no_match_returns_empty() {
321        let entries = vec![EntryRef {
322            key: "folder/file.txt".to_string(),
323            size: 10,
324            last_modified: None,
325            is_prefix: false,
326        }];
327        let result = search_local_filter("pane-1".to_string(), "zzz-no-match".to_string(), entries)
328            .await
329            .unwrap();
330
331        assert!(result.is_empty());
332    }
333
334    #[tokio::test]
335    async fn local_filter_preserves_prefix_entries() {
336        let entries = vec![
337            EntryRef {
338                key: "logs/".to_string(),
339                size: 0,
340                last_modified: None,
341                is_prefix: true,
342            },
343            EntryRef {
344                key: "data/file.csv".to_string(),
345                size: 500,
346                last_modified: None,
347                is_prefix: false,
348            },
349        ];
350        let result = search_local_filter("pane-1".to_string(), "logs".to_string(), entries)
351            .await
352            .unwrap();
353
354        assert_eq!(result.len(), 1);
355        assert!(result[0].is_prefix);
356    }
357
358    // -----------------------------------------------------------------------
359    // SearchRegistry — event emission test
360    // -----------------------------------------------------------------------
361
362    #[test]
363    fn search_registry_register_and_cancel() {
364        let mut reg = SearchRegistry::default();
365        let token = reg.register("req-42");
366        assert!(!token.is_cancelled());
367        assert!(reg.cancel("req-42"));
368        assert!(token.is_cancelled());
369    }
370
371    /// Asserts that `search:page` events are emitted with the correct
372    /// `request_id` in the payload (round-1 finding #14).
373    #[test]
374    fn search_page_event_carries_correct_request_id() {
375        let channel = MockChannel::default();
376        let page = SearchPage {
377            request_id: "req-abc".to_string(),
378            page_index: 0,
379            results: vec![EntryRef {
380                key: "folder/doc.txt".to_string(),
381                size: 42,
382                last_modified: None,
383                is_prefix: false,
384            }],
385            is_final: false,
386        };
387
388        events::emit(&channel, EventKind::SearchPage, &page).unwrap();
389
390        let emitted = channel.emitted();
391        assert_eq!(emitted.len(), 1);
392        assert_eq!(emitted[0].0, EventKind::SearchPage);
393        assert_eq!(emitted[0].1["requestId"], "req-abc");
394        assert_eq!(emitted[0].1["pageIndex"], 0);
395        assert_eq!(emitted[0].1["isFinal"], false);
396        assert_eq!(emitted[0].1["results"][0]["key"], "folder/doc.txt");
397    }
398
399    #[test]
400    fn final_page_has_is_final_true() {
401        let channel = MockChannel::default();
402        let page = SearchPage {
403            request_id: "req-fin".to_string(),
404            page_index: 2,
405            results: vec![],
406            is_final: true,
407        };
408
409        events::emit(&channel, EventKind::SearchPage, &page).unwrap();
410
411        let emitted = channel.emitted();
412        assert_eq!(emitted[0].1["isFinal"], true);
413        assert_eq!(emitted[0].1["requestId"], "req-fin");
414    }
415}