2026-02-14 01:29:34 +01:00
|
|
|
|
use async_trait::async_trait;
|
2025-03-27 01:13:34 +01:00
|
|
|
|
use std::collections::HashMap;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
use std::sync::Arc;
|
2025-03-27 01:13:34 +01:00
|
|
|
|
use std::sync::Mutex;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
use std::time::{Duration, Instant};
|
2025-03-27 01:13:34 +01:00
|
|
|
|
use tokio::time;
|
|
|
|
|
|
|
|
|
|
|
|
use crate::application::dtos::file_dto::FileDto;
|
|
|
|
|
|
use crate::application::dtos::folder_dto::FolderDto;
|
2026-02-15 23:45:11 +01:00
|
|
|
|
use crate::application::dtos::search_dto::{
|
|
|
|
|
|
SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto,
|
|
|
|
|
|
SearchSuggestionItem, SearchSuggestionsDto,
|
|
|
|
|
|
};
|
2025-03-27 01:13:34 +01:00
|
|
|
|
use crate::application::ports::inbound::SearchUseCase;
|
2026-02-08 13:40:23 +01:00
|
|
|
|
use crate::application::ports::outbound::FolderStoragePort;
|
|
|
|
|
|
use crate::application::ports::storage_ports::FileReadPort;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
use crate::common::errors::Result;
|
2025-03-27 01:13:34 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-15 23:45:11 +01:00
|
|
|
|
* High-performance search service implementation for files and folders.
|
2026-02-14 01:29:34 +01:00
|
|
|
|
*
|
2026-02-15 23:45:11 +01:00
|
|
|
|
* All search processing (filtering, scoring, sorting, categorization,
|
|
|
|
|
|
* formatting) is performed server-side in Rust for maximum efficiency.
|
|
|
|
|
|
* The frontend acts as a thin rendering client only.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Features:
|
|
|
|
|
|
* - Parallel recursive folder traversal using tokio tasks
|
|
|
|
|
|
* - Relevance scoring (exact match > starts-with > contains)
|
|
|
|
|
|
* - Content categorization and icon mapping
|
|
|
|
|
|
* - Multiple sort options (relevance, name, date, size)
|
|
|
|
|
|
* - Server-side formatted file sizes
|
|
|
|
|
|
* - Quick suggestions endpoint for autocomplete
|
|
|
|
|
|
* - TTL-based result caching
|
2025-03-27 01:13:34 +01:00
|
|
|
|
*/
|
|
|
|
|
|
pub struct SearchService {
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Repository for file operations
|
2026-02-08 13:40:23 +01:00
|
|
|
|
file_repository: Arc<dyn FileReadPort>,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Repository for folder operations
|
2025-03-27 01:13:34 +01:00
|
|
|
|
folder_repository: Arc<dyn FolderStoragePort>,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Search results cache with expiration time
|
2025-03-27 01:13:34 +01:00
|
|
|
|
search_cache: Arc<Mutex<HashMap<SearchCacheKey, CachedSearchResult>>>,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Cache validity duration in seconds
|
2025-03-27 01:13:34 +01:00
|
|
|
|
cache_ttl: u64,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Maximum cache size (number of stored results)
|
2025-03-27 01:13:34 +01:00
|
|
|
|
max_cache_size: usize,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Key for the search cache
|
2025-03-27 01:13:34 +01:00
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
|
|
|
|
struct SearchCacheKey {
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Serialized representation of the search criteria
|
2025-03-27 01:13:34 +01:00
|
|
|
|
criteria_hash: String,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// User ID (to isolate searches between users)
|
2025-03-27 01:13:34 +01:00
|
|
|
|
user_id: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Cached search result with expiration time
|
2025-03-27 01:13:34 +01:00
|
|
|
|
struct CachedSearchResult {
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Search results
|
2025-03-27 01:13:34 +01:00
|
|
|
|
results: SearchResultsDto,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Time when the cache entry was created
|
2025-03-27 01:13:34 +01:00
|
|
|
|
timestamp: Instant,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
// ─── Utility functions (pure, no self — computed on the server) ─────────
|
|
|
|
|
|
|
|
|
|
|
|
/// Compute relevance score (0–100) for a name against a query.
|
|
|
|
|
|
/// Exact match = 100, starts-with = 80, contains = 50, no match = 0.
|
|
|
|
|
|
fn compute_relevance(name: &str, query: &str) -> u32 {
|
|
|
|
|
|
let name_lower = name.to_lowercase();
|
|
|
|
|
|
let query_lower = query.to_lowercase();
|
|
|
|
|
|
|
|
|
|
|
|
if name_lower == query_lower {
|
|
|
|
|
|
100
|
|
|
|
|
|
} else if name_lower.starts_with(&query_lower) {
|
|
|
|
|
|
80
|
|
|
|
|
|
} else if name_lower.contains(&query_lower) {
|
|
|
|
|
|
// Bonus for shorter names (more specific match)
|
|
|
|
|
|
let ratio = query_lower.len() as f64 / name_lower.len() as f64;
|
|
|
|
|
|
50 + (ratio * 20.0) as u32
|
|
|
|
|
|
} else {
|
|
|
|
|
|
0
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Format bytes into a human-readable string (e.g. "2.5 MB").
|
|
|
|
|
|
fn format_bytes(bytes: u64) -> String {
|
|
|
|
|
|
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
|
|
|
|
|
|
if bytes == 0 {
|
|
|
|
|
|
return "0 B".to_string();
|
|
|
|
|
|
}
|
|
|
|
|
|
let exp = (bytes as f64).log(1024.0).floor() as usize;
|
|
|
|
|
|
let exp = exp.min(UNITS.len() - 1);
|
|
|
|
|
|
let value = bytes as f64 / 1024_f64.powi(exp as i32);
|
|
|
|
|
|
if exp == 0 {
|
|
|
|
|
|
format!("{} B", bytes)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
format!("{:.1} {}", value, UNITS[exp])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Determine content category from MIME type.
|
|
|
|
|
|
fn categorize_mime(mime: &str) -> &'static str {
|
|
|
|
|
|
let m = mime.to_lowercase();
|
|
|
|
|
|
if m.starts_with("image/") {
|
|
|
|
|
|
"image"
|
|
|
|
|
|
} else if m.starts_with("video/") {
|
|
|
|
|
|
"video"
|
|
|
|
|
|
} else if m.starts_with("audio/") {
|
|
|
|
|
|
"audio"
|
|
|
|
|
|
} else if m.starts_with("text/")
|
|
|
|
|
|
|| m.contains("pdf")
|
|
|
|
|
|
|| m.contains("document")
|
|
|
|
|
|
|| m.contains("spreadsheet")
|
|
|
|
|
|
|| m.contains("presentation")
|
|
|
|
|
|
|| m.contains("msword")
|
|
|
|
|
|
|| m.contains("officedocument")
|
|
|
|
|
|
{
|
|
|
|
|
|
"document"
|
|
|
|
|
|
} else if m.contains("zip")
|
|
|
|
|
|
|| m.contains("tar")
|
|
|
|
|
|
|| m.contains("gzip")
|
|
|
|
|
|
|| m.contains("bzip")
|
|
|
|
|
|
|| m.contains("7z")
|
|
|
|
|
|
|| m.contains("rar")
|
|
|
|
|
|
|| m.contains("compress")
|
|
|
|
|
|
{
|
|
|
|
|
|
"archive"
|
|
|
|
|
|
} else if m.contains("json")
|
|
|
|
|
|
|| m.contains("xml")
|
|
|
|
|
|
|| m.contains("javascript")
|
|
|
|
|
|
|| m.contains("typescript")
|
|
|
|
|
|
|| m.contains("x-python")
|
|
|
|
|
|
|| m.contains("x-rust")
|
|
|
|
|
|
|| m.contains("x-c")
|
|
|
|
|
|
|| m.contains("x-java")
|
|
|
|
|
|
|| m.contains("x-shellscript")
|
|
|
|
|
|
|| m.contains("x-httpd-php")
|
|
|
|
|
|
|| m.contains("yaml")
|
|
|
|
|
|
|| m.contains("toml")
|
|
|
|
|
|
{
|
|
|
|
|
|
"code"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"other"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get Font Awesome icon class for a file based on extension and MIME type.
|
|
|
|
|
|
fn get_icon_class(name: &str, mime: &str) -> String {
|
|
|
|
|
|
// Try extension first
|
|
|
|
|
|
if let Some(ext) = name.rsplit('.').next() {
|
|
|
|
|
|
let ext_lower = ext.to_lowercase();
|
|
|
|
|
|
let icon = match ext_lower.as_str() {
|
|
|
|
|
|
// Documents
|
|
|
|
|
|
"pdf" => "fas fa-file-pdf",
|
|
|
|
|
|
"doc" | "docx" => "fas fa-file-word",
|
|
|
|
|
|
"xls" | "xlsx" => "fas fa-file-excel",
|
|
|
|
|
|
"ppt" | "pptx" => "fas fa-file-powerpoint",
|
|
|
|
|
|
"txt" | "rtf" | "md" => "fas fa-file-alt",
|
|
|
|
|
|
"csv" => "fas fa-file-csv",
|
|
|
|
|
|
// Images
|
|
|
|
|
|
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" => {
|
|
|
|
|
|
"fas fa-file-image"
|
|
|
|
|
|
}
|
|
|
|
|
|
// Video
|
|
|
|
|
|
"mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => {
|
|
|
|
|
|
"fas fa-file-video"
|
|
|
|
|
|
}
|
|
|
|
|
|
// Audio
|
|
|
|
|
|
"mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" => "fas fa-file-audio",
|
|
|
|
|
|
// Archives
|
|
|
|
|
|
"zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" => "fas fa-file-archive",
|
|
|
|
|
|
// Code
|
|
|
|
|
|
"js" | "ts" | "jsx" | "tsx" | "py" | "rs" | "go" | "java" | "c" | "cpp" | "cs"
|
|
|
|
|
|
| "rb" | "php" | "swift" | "kt" | "scala" | "r" | "lua" | "pl" | "sh" | "bash"
|
|
|
|
|
|
| "zsh" | "fish" | "ps1" | "bat" | "cmd" => "fas fa-file-code",
|
|
|
|
|
|
"html" | "htm" | "css" | "scss" | "sass" | "less" => "fas fa-file-code",
|
|
|
|
|
|
"json" | "xml" | "yaml" | "yml" | "toml" | "ini" | "cfg" | "conf" => {
|
|
|
|
|
|
"fas fa-file-code"
|
|
|
|
|
|
}
|
|
|
|
|
|
"sql" => "fas fa-database",
|
|
|
|
|
|
_ => "",
|
|
|
|
|
|
};
|
|
|
|
|
|
if !icon.is_empty() {
|
|
|
|
|
|
return icon.to_string();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fallback to MIME type
|
|
|
|
|
|
let category = categorize_mime(mime);
|
|
|
|
|
|
match category {
|
|
|
|
|
|
"image" => "fas fa-file-image",
|
|
|
|
|
|
"video" => "fas fa-file-video",
|
|
|
|
|
|
"audio" => "fas fa-file-audio",
|
|
|
|
|
|
"document" => "fas fa-file-alt",
|
|
|
|
|
|
"archive" => "fas fa-file-archive",
|
|
|
|
|
|
"code" => "fas fa-file-code",
|
|
|
|
|
|
_ => "fas fa-file",
|
|
|
|
|
|
}
|
|
|
|
|
|
.to_string()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── SearchService implementation ───────────────────────────────────────
|
|
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
impl SearchService {
|
|
|
|
|
|
/**
|
2026-02-12 09:41:25 +01:00
|
|
|
|
* Creates a new instance of the search service.
|
2025-03-27 01:13:34 +01:00
|
|
|
|
*/
|
|
|
|
|
|
pub fn new(
|
2026-02-08 13:40:23 +01:00
|
|
|
|
file_repository: Arc<dyn FileReadPort>,
|
2025-03-27 01:13:34 +01:00
|
|
|
|
folder_repository: Arc<dyn FolderStoragePort>,
|
|
|
|
|
|
cache_ttl: u64,
|
|
|
|
|
|
max_cache_size: usize,
|
|
|
|
|
|
) -> Self {
|
|
|
|
|
|
let search_service = Self {
|
|
|
|
|
|
file_repository,
|
|
|
|
|
|
folder_repository,
|
|
|
|
|
|
search_cache: Arc::new(Mutex::new(HashMap::new())),
|
|
|
|
|
|
cache_ttl,
|
|
|
|
|
|
max_cache_size,
|
|
|
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
// Start cache cleanup task if TTL > 0
|
2025-03-27 01:13:34 +01:00
|
|
|
|
if cache_ttl > 0 {
|
|
|
|
|
|
Self::start_cache_cleanup_task(search_service.search_cache.clone(), cache_ttl);
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
search_service
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
/// Starts an asynchronous task to clean up expired cache entries.
|
2025-03-27 01:13:34 +01:00
|
|
|
|
fn start_cache_cleanup_task(
|
|
|
|
|
|
cache_ref: Arc<Mutex<HashMap<SearchCacheKey, CachedSearchResult>>>,
|
|
|
|
|
|
ttl_seconds: u64,
|
|
|
|
|
|
) {
|
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
|
let cleanup_interval = Duration::from_secs(ttl_seconds / 2);
|
|
|
|
|
|
let ttl = Duration::from_secs(ttl_seconds);
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
loop {
|
|
|
|
|
|
time::sleep(cleanup_interval).await;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
if let Ok(mut cache) = cache_ref.lock() {
|
|
|
|
|
|
let now = Instant::now();
|
|
|
|
|
|
let expired_keys: Vec<SearchCacheKey> = cache
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|(_, result)| now.duration_since(result.timestamp) > ttl)
|
|
|
|
|
|
.map(|(key, _)| key.clone())
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
for key in expired_keys {
|
|
|
|
|
|
cache.remove(&key);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
/// Creates a cache key from the search criteria.
|
2025-03-27 01:13:34 +01:00
|
|
|
|
fn create_cache_key(&self, criteria: &SearchCriteriaDto, user_id: &str) -> SearchCacheKey {
|
|
|
|
|
|
let criteria_str = serde_json::to_string(criteria).unwrap_or_default();
|
|
|
|
|
|
SearchCacheKey {
|
|
|
|
|
|
criteria_hash: criteria_str,
|
|
|
|
|
|
user_id: user_id.to_string(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
/// Attempts to retrieve results from the cache.
|
2025-03-27 01:13:34 +01:00
|
|
|
|
fn get_from_cache(&self, key: &SearchCacheKey) -> Option<SearchResultsDto> {
|
|
|
|
|
|
if self.cache_ttl == 0 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-14 01:26:02 +01:00
|
|
|
|
if let Ok(cache) = self.search_cache.lock()
|
2026-02-14 01:29:34 +01:00
|
|
|
|
&& let Some(cached_result) = cache.get(key)
|
|
|
|
|
|
{
|
|
|
|
|
|
let now = Instant::now();
|
|
|
|
|
|
let ttl = Duration::from_secs(self.cache_ttl);
|
|
|
|
|
|
if now.duration_since(cached_result.timestamp) < ttl {
|
|
|
|
|
|
return Some(cached_result.results.clone());
|
2025-03-27 01:13:34 +01:00
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
None
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
/// Stores results in the cache.
|
2025-03-27 01:13:34 +01:00
|
|
|
|
fn store_in_cache(&self, key: SearchCacheKey, results: SearchResultsDto) {
|
|
|
|
|
|
if self.cache_ttl == 0 {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
if let Ok(mut cache) = self.search_cache.lock() {
|
2026-02-14 01:26:02 +01:00
|
|
|
|
if cache.len() >= self.max_cache_size
|
2026-02-14 01:29:34 +01:00
|
|
|
|
&& let Some((oldest_key, _)) =
|
|
|
|
|
|
cache.iter().min_by_key(|(_, result)| result.timestamp)
|
|
|
|
|
|
{
|
|
|
|
|
|
let key_to_remove = oldest_key.clone();
|
|
|
|
|
|
cache.remove(&key_to_remove);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cache.insert(
|
|
|
|
|
|
key,
|
|
|
|
|
|
CachedSearchResult {
|
|
|
|
|
|
results,
|
|
|
|
|
|
timestamp: Instant::now(),
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2025-03-27 01:13:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
/// Enrich a FileDto → SearchFileResultDto with server-computed metadata.
|
|
|
|
|
|
fn enrich_file(file: &FileDto, query: &str) -> SearchFileResultDto {
|
|
|
|
|
|
let relevance = if query.is_empty() {
|
|
|
|
|
|
50
|
|
|
|
|
|
} else {
|
|
|
|
|
|
compute_relevance(&file.name, query)
|
|
|
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
SearchFileResultDto {
|
|
|
|
|
|
id: file.id.clone(),
|
|
|
|
|
|
name: file.name.clone(),
|
|
|
|
|
|
path: file.path.clone(),
|
|
|
|
|
|
size: file.size,
|
|
|
|
|
|
mime_type: file.mime_type.clone(),
|
|
|
|
|
|
folder_id: file.folder_id.clone(),
|
|
|
|
|
|
created_at: file.created_at,
|
|
|
|
|
|
modified_at: file.modified_at,
|
|
|
|
|
|
relevance_score: relevance,
|
|
|
|
|
|
size_formatted: format_bytes(file.size),
|
|
|
|
|
|
icon_class: get_icon_class(&file.name, &file.mime_type),
|
|
|
|
|
|
category: categorize_mime(&file.mime_type).to_string(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
/// Enrich a FolderDto → SearchFolderResultDto with server-computed metadata.
|
|
|
|
|
|
fn enrich_folder(folder: &FolderDto, query: &str) -> SearchFolderResultDto {
|
|
|
|
|
|
let relevance = if query.is_empty() {
|
|
|
|
|
|
50
|
|
|
|
|
|
} else {
|
|
|
|
|
|
compute_relevance(&folder.name, query)
|
|
|
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
SearchFolderResultDto {
|
|
|
|
|
|
id: folder.id.clone(),
|
|
|
|
|
|
name: folder.name.clone(),
|
|
|
|
|
|
path: folder.path.clone(),
|
|
|
|
|
|
parent_id: folder.parent_id.clone(),
|
|
|
|
|
|
created_at: folder.created_at,
|
|
|
|
|
|
modified_at: folder.modified_at,
|
|
|
|
|
|
is_root: folder.is_root,
|
|
|
|
|
|
relevance_score: relevance,
|
|
|
|
|
|
}
|
2025-03-27 01:13:34 +01:00
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
/**
|
2026-02-15 23:45:11 +01:00
|
|
|
|
* Parallel recursive search through folders using tokio tasks.
|
2026-02-14 01:29:34 +01:00
|
|
|
|
*
|
2026-02-15 23:45:11 +01:00
|
|
|
|
* Instead of searching subfolders sequentially, we spawn a task
|
|
|
|
|
|
* per subfolder and join them all concurrently.
|
2025-03-27 01:13:34 +01:00
|
|
|
|
*/
|
2026-02-15 23:45:11 +01:00
|
|
|
|
fn search_parallel(
|
|
|
|
|
|
file_repo: Arc<dyn FileReadPort>,
|
|
|
|
|
|
folder_repo: Arc<dyn FolderStoragePort>,
|
|
|
|
|
|
current_folder_id: Option<String>,
|
|
|
|
|
|
criteria: Arc<SearchCriteriaDto>,
|
|
|
|
|
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(Vec<FileDto>, Vec<FolderDto>)>> + Send>> {
|
|
|
|
|
|
Box::pin(async move {
|
|
|
|
|
|
// List files in the current folder
|
|
|
|
|
|
let files = file_repo
|
|
|
|
|
|
.list_files(current_folder_id.as_deref())
|
|
|
|
|
|
.await?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
let filtered_files: Vec<FileDto> = files
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(FileDto::from)
|
|
|
|
|
|
.filter(|file| passes_file_filter(file, &criteria))
|
|
|
|
|
|
.collect();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
let mut all_files = filtered_files;
|
|
|
|
|
|
let mut all_folders: Vec<FolderDto> = Vec::new();
|
|
|
|
|
|
|
|
|
|
|
|
// If recursive, process subfolders in parallel
|
|
|
|
|
|
if criteria.recursive {
|
|
|
|
|
|
let folders = folder_repo
|
|
|
|
|
|
.list_folders(current_folder_id.as_deref())
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
|
|
let folder_dtos: Vec<FolderDto> = folders
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(FolderDto::from)
|
|
|
|
|
|
.filter(|f| passes_folder_filter(f, &criteria))
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
|
|
all_folders.extend(folder_dtos.iter().cloned());
|
|
|
|
|
|
|
|
|
|
|
|
// Spawn parallel tasks for each subfolder
|
|
|
|
|
|
let mut handles = Vec::with_capacity(folder_dtos.len());
|
|
|
|
|
|
for subfolder in &folder_dtos {
|
|
|
|
|
|
let fr = file_repo.clone();
|
|
|
|
|
|
let fdr = folder_repo.clone();
|
|
|
|
|
|
let crit = criteria.clone();
|
|
|
|
|
|
let folder_id = subfolder.id.clone();
|
|
|
|
|
|
|
|
|
|
|
|
handles.push(tokio::spawn(async move {
|
|
|
|
|
|
Self::search_parallel(fr, fdr, Some(folder_id), crit).await
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
// Collect results from all parallel tasks
|
|
|
|
|
|
for handle in handles {
|
|
|
|
|
|
match handle.await {
|
|
|
|
|
|
Ok(Ok((sub_files, sub_folders))) => {
|
|
|
|
|
|
all_files.extend(sub_files);
|
|
|
|
|
|
all_folders.extend(sub_folders);
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(Err(e)) => {
|
|
|
|
|
|
tracing::warn!("Parallel search subtask error: {}", e);
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
tracing::warn!("Parallel search task join error: {}", e);
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
}
|
2026-02-15 23:45:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
Ok((all_files, all_folders))
|
|
|
|
|
|
}) // end Box::pin
|
2025-03-27 01:13:34 +01:00
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
/// Quick suggestions search — returns up to `limit` name suggestions
|
|
|
|
|
|
/// matching the query prefix. Uses cache-friendly shallow search.
|
|
|
|
|
|
pub async fn suggest(
|
2025-03-27 01:13:34 +01:00
|
|
|
|
&self,
|
2026-02-15 23:45:11 +01:00
|
|
|
|
query: &str,
|
|
|
|
|
|
folder_id: Option<&str>,
|
|
|
|
|
|
limit: usize,
|
|
|
|
|
|
) -> Result<SearchSuggestionsDto> {
|
|
|
|
|
|
let start = Instant::now();
|
|
|
|
|
|
let query_lower = query.to_lowercase();
|
|
|
|
|
|
|
|
|
|
|
|
let mut suggestions: Vec<SearchSuggestionItem> = Vec::new();
|
|
|
|
|
|
|
|
|
|
|
|
// List files in the folder
|
|
|
|
|
|
let files = self.file_repository.list_files(folder_id).await?;
|
|
|
|
|
|
for file in files {
|
|
|
|
|
|
let file_dto = FileDto::from(file);
|
|
|
|
|
|
if file_dto.name.to_lowercase().contains(&query_lower) {
|
|
|
|
|
|
let score = compute_relevance(&file_dto.name, query);
|
|
|
|
|
|
suggestions.push(SearchSuggestionItem {
|
|
|
|
|
|
name: file_dto.name.clone(),
|
|
|
|
|
|
item_type: "file".to_string(),
|
|
|
|
|
|
id: file_dto.id.clone(),
|
|
|
|
|
|
path: file_dto.path.clone(),
|
|
|
|
|
|
icon_class: get_icon_class(&file_dto.name, &file_dto.mime_type),
|
|
|
|
|
|
relevance_score: score,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if suggestions.len() >= limit * 2 {
|
|
|
|
|
|
break; // Collect enough candidates
|
2025-03-27 01:13:34 +01:00
|
|
|
|
}
|
2026-02-15 23:45:11 +01:00
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
// List folders
|
|
|
|
|
|
let folders = self.folder_repository.list_folders(folder_id).await?;
|
|
|
|
|
|
for folder in folders {
|
|
|
|
|
|
let folder_dto = FolderDto::from(folder);
|
|
|
|
|
|
if folder_dto.name.to_lowercase().contains(&query_lower) {
|
|
|
|
|
|
let score = compute_relevance(&folder_dto.name, query);
|
|
|
|
|
|
suggestions.push(SearchSuggestionItem {
|
|
|
|
|
|
name: folder_dto.name.clone(),
|
|
|
|
|
|
item_type: "folder".to_string(),
|
|
|
|
|
|
id: folder_dto.id.clone(),
|
|
|
|
|
|
path: folder_dto.path.clone(),
|
|
|
|
|
|
icon_class: "fas fa-folder".to_string(),
|
|
|
|
|
|
relevance_score: score,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Sort by relevance and truncate
|
|
|
|
|
|
suggestions.sort_by(|a, b| b.relevance_score.cmp(&a.relevance_score));
|
|
|
|
|
|
suggestions.truncate(limit);
|
|
|
|
|
|
|
|
|
|
|
|
let elapsed = start.elapsed().as_millis() as u64;
|
|
|
|
|
|
Ok(SearchSuggestionsDto {
|
|
|
|
|
|
suggestions,
|
|
|
|
|
|
query_time_ms: elapsed,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
})
|
2025-03-27 01:13:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
// ─── Standalone filter functions for use in parallel tasks ──────────────
|
|
|
|
|
|
|
|
|
|
|
|
/// Check if a file passes all filter criteria (standalone, no &self needed).
|
|
|
|
|
|
fn passes_file_filter(file: &FileDto, criteria: &SearchCriteriaDto) -> bool {
|
|
|
|
|
|
if let Some(name_query) = &criteria.name_contains
|
|
|
|
|
|
&& !file
|
|
|
|
|
|
.name
|
|
|
|
|
|
.to_lowercase()
|
|
|
|
|
|
.contains(&name_query.to_lowercase())
|
|
|
|
|
|
{
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(file_types) = &criteria.file_types {
|
|
|
|
|
|
if let Some(extension) = file.name.split('.').next_back() {
|
|
|
|
|
|
if !file_types
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|ext| ext.eq_ignore_ascii_case(extension))
|
|
|
|
|
|
{
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(v) = criteria.created_after {
|
|
|
|
|
|
if file.created_at < v {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(v) = criteria.created_before {
|
|
|
|
|
|
if file.created_at > v {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(v) = criteria.modified_after {
|
|
|
|
|
|
if file.modified_at < v {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(v) = criteria.modified_before {
|
|
|
|
|
|
if file.modified_at > v {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(v) = criteria.min_size {
|
|
|
|
|
|
if file.size < v {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(v) = criteria.max_size {
|
|
|
|
|
|
if file.size > v {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Check if a folder passes all filter criteria (standalone).
|
|
|
|
|
|
fn passes_folder_filter(folder: &FolderDto, criteria: &SearchCriteriaDto) -> bool {
|
|
|
|
|
|
if let Some(name_query) = &criteria.name_contains
|
|
|
|
|
|
&& !folder
|
|
|
|
|
|
.name
|
|
|
|
|
|
.to_lowercase()
|
|
|
|
|
|
.contains(&name_query.to_lowercase())
|
|
|
|
|
|
{
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(v) = criteria.created_after {
|
|
|
|
|
|
if folder.created_at < v {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(v) = criteria.created_before {
|
|
|
|
|
|
if folder.created_at > v {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(v) = criteria.modified_after {
|
|
|
|
|
|
if folder.modified_at < v {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(v) = criteria.modified_before {
|
|
|
|
|
|
if folder.modified_at > v {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── SearchUseCase trait implementation ──────────────────────────────────
|
|
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
#[async_trait]
|
|
|
|
|
|
impl SearchUseCase for SearchService {
|
|
|
|
|
|
/**
|
2026-02-12 09:41:25 +01:00
|
|
|
|
* Performs a search based on the specified criteria.
|
2026-02-14 01:29:34 +01:00
|
|
|
|
*
|
2026-02-15 23:45:11 +01:00
|
|
|
|
* All processing happens server-side:
|
|
|
|
|
|
* - Parallel recursive traversal
|
|
|
|
|
|
* - Filtering by name, type, dates, size
|
|
|
|
|
|
* - Relevance scoring
|
|
|
|
|
|
* - Sorting (relevance, name, date, size)
|
|
|
|
|
|
* - Content categorization & icon mapping
|
|
|
|
|
|
* - Human-readable size formatting
|
|
|
|
|
|
* - Pagination
|
2025-03-27 01:13:34 +01:00
|
|
|
|
*/
|
|
|
|
|
|
async fn search(&self, criteria: SearchCriteriaDto) -> Result<SearchResultsDto> {
|
2026-02-15 23:45:11 +01:00
|
|
|
|
let start = Instant::now();
|
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
// TODO: Get user ID from the authentication context
|
2025-03-27 01:13:34 +01:00
|
|
|
|
let user_id = "default-user";
|
|
|
|
|
|
let cache_key = self.create_cache_key(&criteria, user_id);
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
// Try cache
|
2025-03-27 01:13:34 +01:00
|
|
|
|
if let Some(cached_results) = self.get_from_cache(&cache_key) {
|
|
|
|
|
|
return Ok(cached_results);
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
// ── Parallel recursive search ──
|
|
|
|
|
|
let criteria_arc = Arc::new(criteria.clone());
|
|
|
|
|
|
let (found_files, found_folders) = Self::search_parallel(
|
|
|
|
|
|
self.file_repository.clone(),
|
|
|
|
|
|
self.folder_repository.clone(),
|
|
|
|
|
|
criteria.folder_id.clone(),
|
|
|
|
|
|
criteria_arc,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
let query = criteria.name_contains.as_deref().unwrap_or("");
|
|
|
|
|
|
|
|
|
|
|
|
// ── Enrich results with server-computed metadata ──
|
|
|
|
|
|
let mut enriched_files: Vec<SearchFileResultDto> = found_files
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|f| Self::enrich_file(f, query))
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
|
|
let mut enriched_folders: Vec<SearchFolderResultDto> = found_folders
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|f| Self::enrich_folder(f, query))
|
|
|
|
|
|
.collect();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
// ── Sort based on criteria.sort_by ──
|
|
|
|
|
|
match criteria.sort_by.as_str() {
|
|
|
|
|
|
"name" => {
|
|
|
|
|
|
enriched_files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
|
|
|
|
|
enriched_folders
|
|
|
|
|
|
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
|
|
|
|
|
}
|
|
|
|
|
|
"name_desc" => {
|
|
|
|
|
|
enriched_files.sort_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase()));
|
|
|
|
|
|
enriched_folders
|
|
|
|
|
|
.sort_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase()));
|
|
|
|
|
|
}
|
|
|
|
|
|
"date" => {
|
|
|
|
|
|
enriched_files.sort_by(|a, b| a.modified_at.cmp(&b.modified_at));
|
|
|
|
|
|
enriched_folders.sort_by(|a, b| a.modified_at.cmp(&b.modified_at));
|
|
|
|
|
|
}
|
|
|
|
|
|
"date_desc" => {
|
|
|
|
|
|
enriched_files.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
|
|
|
|
|
enriched_folders.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
|
|
|
|
|
}
|
|
|
|
|
|
"size" => {
|
|
|
|
|
|
enriched_files.sort_by(|a, b| a.size.cmp(&b.size));
|
|
|
|
|
|
}
|
|
|
|
|
|
"size_desc" => {
|
|
|
|
|
|
enriched_files.sort_by(|a, b| b.size.cmp(&a.size));
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {
|
|
|
|
|
|
// "relevance" (default) — highest relevance first, tie-break by date desc
|
|
|
|
|
|
enriched_files.sort_by(|a, b| {
|
|
|
|
|
|
b.relevance_score
|
|
|
|
|
|
.cmp(&a.relevance_score)
|
|
|
|
|
|
.then_with(|| b.modified_at.cmp(&a.modified_at))
|
|
|
|
|
|
});
|
|
|
|
|
|
enriched_folders.sort_by(|a, b| {
|
|
|
|
|
|
b.relevance_score
|
|
|
|
|
|
.cmp(&a.relevance_score)
|
|
|
|
|
|
.then_with(|| b.modified_at.cmp(&a.modified_at))
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
// ── Pagination ──
|
|
|
|
|
|
let total_count = enriched_files.len() + enriched_folders.len();
|
2025-03-27 01:13:34 +01:00
|
|
|
|
let start_idx = criteria.offset.min(total_count);
|
|
|
|
|
|
let end_idx = (criteria.offset + criteria.limit).min(total_count);
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
let paginated_items: Vec<(bool, usize)> = (start_idx..end_idx)
|
|
|
|
|
|
.map(|i| {
|
2026-02-15 23:45:11 +01:00
|
|
|
|
if i < enriched_folders.len() {
|
|
|
|
|
|
(true, i) // folder
|
2025-03-27 01:13:34 +01:00
|
|
|
|
} else {
|
2026-02-15 23:45:11 +01:00
|
|
|
|
(false, i - enriched_folders.len()) // file
|
2025-03-27 01:13:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
let mut paginated_folders = Vec::new();
|
|
|
|
|
|
let mut paginated_files = Vec::new();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
for (is_folder, idx) in paginated_items {
|
|
|
|
|
|
if is_folder {
|
2026-02-15 23:45:11 +01:00
|
|
|
|
if idx < enriched_folders.len() {
|
|
|
|
|
|
paginated_folders.push(enriched_folders[idx].clone());
|
2025-03-27 01:13:34 +01:00
|
|
|
|
}
|
2026-02-15 23:45:11 +01:00
|
|
|
|
} else if idx < enriched_files.len() {
|
|
|
|
|
|
paginated_files.push(enriched_files[idx].clone());
|
2025-03-27 01:13:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
let elapsed_ms = start.elapsed().as_millis() as u64;
|
|
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
let search_results = SearchResultsDto::new(
|
|
|
|
|
|
paginated_files,
|
|
|
|
|
|
paginated_folders,
|
|
|
|
|
|
criteria.limit,
|
|
|
|
|
|
criteria.offset,
|
|
|
|
|
|
Some(total_count),
|
2026-02-15 23:45:11 +01:00
|
|
|
|
elapsed_ms,
|
|
|
|
|
|
criteria.sort_by.clone(),
|
2025-03-27 01:13:34 +01:00
|
|
|
|
);
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
// Store in cache
|
2025-03-27 01:13:34 +01:00
|
|
|
|
self.store_in_cache(cache_key, search_results.clone());
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
Ok(search_results)
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
/// Returns quick suggestions for autocomplete.
|
|
|
|
|
|
async fn suggest(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
query: &str,
|
|
|
|
|
|
folder_id: Option<&str>,
|
|
|
|
|
|
limit: usize,
|
|
|
|
|
|
) -> Result<SearchSuggestionsDto> {
|
|
|
|
|
|
self.suggest(query, folder_id, limit).await
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Clears the search results cache.
|
2025-03-27 01:13:34 +01:00
|
|
|
|
async fn clear_search_cache(&self) -> Result<()> {
|
|
|
|
|
|
if let Ok(mut cache) = self.search_cache.lock() {
|
|
|
|
|
|
cache.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
// ── Stub for testing ────────────────────────────────────────────────────
|
|
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
impl SearchService {
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Creates a stub version of the service for testing
|
2025-03-27 01:13:34 +01:00
|
|
|
|
pub fn new_stub() -> impl SearchUseCase {
|
|
|
|
|
|
struct SearchServiceStub;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
#[async_trait]
|
|
|
|
|
|
impl SearchUseCase for SearchServiceStub {
|
|
|
|
|
|
async fn search(&self, _criteria: SearchCriteriaDto) -> Result<SearchResultsDto> {
|
|
|
|
|
|
Ok(SearchResultsDto::empty())
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
async fn suggest(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
_query: &str,
|
|
|
|
|
|
_folder_id: Option<&str>,
|
|
|
|
|
|
_limit: usize,
|
|
|
|
|
|
) -> Result<SearchSuggestionsDto> {
|
|
|
|
|
|
Ok(SearchSuggestionsDto {
|
|
|
|
|
|
suggestions: Vec::new(),
|
|
|
|
|
|
query_time_ms: 0,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
async fn clear_search_cache(&self) -> Result<()> {
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
SearchServiceStub
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
}
|