2026-03-02 01:30:34 +01:00
|
|
|
|
use std::cmp::Reverse;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
use std::time::{Duration, Instant};
|
2025-03-27 01:13:34 +01:00
|
|
|
|
|
2026-02-21 13:33:18 +01:00
|
|
|
|
use crate::application::dtos::display_helpers::{
|
|
|
|
|
|
category_for, icon_class_for, icon_special_class_for,
|
|
|
|
|
|
};
|
2025-03-27 01:13:34 +01:00
|
|
|
|
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::storage_ports::FileReadPort;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
use crate::common::errors::Result;
|
2026-02-23 00:17:40 +01:00
|
|
|
|
use crate::domain::entities::folder::Folder;
|
2026-03-04 23:55:08 +01:00
|
|
|
|
use crate::domain::repositories::folder_repository::FolderRepository;
|
2026-03-03 15:36:42 +00:00
|
|
|
|
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
|
|
|
|
|
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
2026-03-04 23:55:08 +01:00
|
|
|
|
use std::hash::{Hash, Hasher};
|
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:
|
2026-02-23 00:17:40 +01:00
|
|
|
|
* - Single-query recursive subtree search via PostgreSQL ltree
|
2026-02-15 23:45:11 +01:00
|
|
|
|
* - 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-03-03 15:36:42 +00:00
|
|
|
|
file_repository: Arc<FileBlobReadRepository>,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Repository for folder operations
|
2026-03-03 15:36:42 +00:00
|
|
|
|
folder_repository: Arc<FolderDbRepository>,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-24 12:58:47 +01:00
|
|
|
|
/// Lock-free concurrent cache with automatic TTL and LRU eviction (moka).
|
2026-02-25 23:47:51 +01:00
|
|
|
|
/// Values are `Arc<SearchResultsDto>` so cache insert/hit is a single
|
|
|
|
|
|
/// atomic ref-count increment (~1 ns) instead of cloning thousands of Strings.
|
|
|
|
|
|
search_cache: moka::future::Cache<u64, Arc<SearchResultsDto>>,
|
2025-03-27 01:13:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
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.
|
2026-03-07 11:23:56 +01:00
|
|
|
|
///
|
|
|
|
|
|
/// `query_lower` **must** already be lowercased by the caller so that the
|
|
|
|
|
|
/// allocation happens once per search, not once per result.
|
|
|
|
|
|
fn compute_relevance(name: &str, query_lower: &str) -> u32 {
|
2026-02-15 23:45:11 +01:00
|
|
|
|
let name_lower = name.to_lowercase();
|
|
|
|
|
|
|
|
|
|
|
|
if name_lower == query_lower {
|
|
|
|
|
|
100
|
2026-03-07 11:23:56 +01:00
|
|
|
|
} else if name_lower.starts_with(query_lower) {
|
2026-02-15 23:45:11 +01:00
|
|
|
|
80
|
2026-03-07 11:23:56 +01:00
|
|
|
|
} else if name_lower.contains(query_lower) {
|
2026-02-15 23:45:11 +01:00
|
|
|
|
// 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])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get Font Awesome icon class for a file based on extension and MIME type.
|
2026-02-18 10:53:43 +01:00
|
|
|
|
/// Delegates to the centralised `display_helpers` so every API surface is
|
|
|
|
|
|
/// consistent.
|
2026-02-15 23:45:11 +01:00
|
|
|
|
fn get_icon_class(name: &str, mime: &str) -> String {
|
2026-02-18 10:53:43 +01:00
|
|
|
|
icon_class_for(name, mime).to_string()
|
|
|
|
|
|
}
|
2026-02-15 23:45:11 +01:00
|
|
|
|
|
2026-02-18 10:53:43 +01:00
|
|
|
|
/// Get CSS special class for icon styling.
|
|
|
|
|
|
fn get_icon_special_class(name: &str, mime: &str) -> String {
|
|
|
|
|
|
icon_special_class_for(name, mime).to_string()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get category label from centralised helpers.
|
|
|
|
|
|
fn get_category(name: &str, mime: &str) -> String {
|
|
|
|
|
|
category_for(name, mime).to_string()
|
2026-02-15 23:45:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 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-03-03 15:36:42 +00:00
|
|
|
|
file_repository: Arc<FileBlobReadRepository>,
|
|
|
|
|
|
folder_repository: Arc<FolderDbRepository>,
|
2025-03-27 01:13:34 +01:00
|
|
|
|
cache_ttl: u64,
|
|
|
|
|
|
max_cache_size: usize,
|
|
|
|
|
|
) -> Self {
|
2026-02-24 12:58:47 +01:00
|
|
|
|
let search_cache = moka::future::Cache::builder()
|
2026-02-22 22:37:36 +01:00
|
|
|
|
.max_capacity(max_cache_size as u64)
|
|
|
|
|
|
.time_to_live(Duration::from_secs(cache_ttl))
|
|
|
|
|
|
.build();
|
|
|
|
|
|
|
|
|
|
|
|
Self {
|
2025-03-27 01:13:34 +01:00
|
|
|
|
file_repository,
|
|
|
|
|
|
folder_repository,
|
2026-02-22 22:37:36 +01:00
|
|
|
|
search_cache,
|
2025-03-27 01:13:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-23 00:17:40 +01:00
|
|
|
|
/// Creates a cache key from the search criteria using zero-allocation hashing.
|
|
|
|
|
|
fn create_cache_key(criteria: &SearchCriteriaDto, user_id: &str) -> u64 {
|
|
|
|
|
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
|
|
|
|
criteria.hash(&mut hasher);
|
|
|
|
|
|
user_id.hash(&mut hasher);
|
|
|
|
|
|
hasher.finish()
|
2025-03-27 01:13:34 +01:00
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
/// Attempts to retrieve results from the cache.
|
2026-02-25 23:47:51 +01:00
|
|
|
|
async fn get_from_cache(&self, key: u64) -> Option<Arc<SearchResultsDto>> {
|
2026-02-24 12:58:47 +01:00
|
|
|
|
self.search_cache.get(&key).await
|
2025-03-27 01:13:34 +01:00
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
/// Stores results in the cache.
|
2026-02-25 23:47:51 +01:00
|
|
|
|
async fn store_in_cache(&self, key: u64, results: Arc<SearchResultsDto>) {
|
2026-02-24 12:58:47 +01:00
|
|
|
|
self.search_cache.insert(key, results).await;
|
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.
|
2026-03-07 11:23:56 +01:00
|
|
|
|
///
|
|
|
|
|
|
/// `query_lower` must already be lowercased (empty string when no query).
|
|
|
|
|
|
fn enrich_file(file: &FileDto, query_lower: &str) -> SearchFileResultDto {
|
|
|
|
|
|
let relevance = if query_lower.is_empty() {
|
2026-02-15 23:45:11 +01:00
|
|
|
|
50
|
|
|
|
|
|
} else {
|
2026-03-07 11:23:56 +01:00
|
|
|
|
compute_relevance(&file.name, query_lower)
|
2026-02-15 23:45:11 +01:00
|
|
|
|
};
|
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,
|
2026-03-03 15:55:15 +00:00
|
|
|
|
mime_type: file.mime_type.to_string(),
|
2026-02-15 23:45:11 +01:00
|
|
|
|
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),
|
2026-02-18 10:53:43 +01:00
|
|
|
|
icon_special_class: get_icon_special_class(&file.name, &file.mime_type),
|
|
|
|
|
|
category: get_category(&file.name, &file.mime_type),
|
2026-02-15 23:45:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
/// Enrich a FolderDto → SearchFolderResultDto with server-computed metadata.
|
2026-03-07 11:23:56 +01:00
|
|
|
|
///
|
|
|
|
|
|
/// `query_lower` must already be lowercased (empty string when no query).
|
|
|
|
|
|
fn enrich_folder(folder: &FolderDto, query_lower: &str) -> SearchFolderResultDto {
|
|
|
|
|
|
let relevance = if query_lower.is_empty() {
|
2026-02-15 23:45:11 +01:00
|
|
|
|
50
|
|
|
|
|
|
} else {
|
2026-03-07 11:23:56 +01:00
|
|
|
|
compute_relevance(&folder.name, query_lower)
|
2026-02-15 23:45:11 +01:00
|
|
|
|
};
|
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
|
|
|
|
|
2026-02-15 23:45:11 +01:00
|
|
|
|
/// Quick suggestions search — returns up to `limit` name suggestions
|
2026-02-23 23:36:38 +01:00
|
|
|
|
/// matching the query. Pushes filtering, relevance sort and LIMIT to SQL
|
|
|
|
|
|
/// so only a handful of rows cross the DB→app boundary.
|
2026-02-15 23:45:11 +01:00
|
|
|
|
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();
|
|
|
|
|
|
|
2026-02-23 23:36:38 +01:00
|
|
|
|
// Ask SQL for at most `limit` best-matching files and folders
|
|
|
|
|
|
let (files, folders) = tokio::join!(
|
|
|
|
|
|
self.file_repository
|
|
|
|
|
|
.suggest_files_by_name(folder_id, query, limit),
|
|
|
|
|
|
self.folder_repository
|
|
|
|
|
|
.suggest_folders_by_name(folder_id, query, limit),
|
|
|
|
|
|
);
|
|
|
|
|
|
let files = files?;
|
|
|
|
|
|
let folders = folders?;
|
2026-02-15 23:45:11 +01:00
|
|
|
|
|
2026-02-23 23:36:38 +01:00
|
|
|
|
let mut suggestions: Vec<SearchSuggestionItem> =
|
|
|
|
|
|
Vec::with_capacity(files.len() + folders.len());
|
|
|
|
|
|
|
2026-03-07 11:23:56 +01:00
|
|
|
|
// Pre-compute once — avoids N heap allocations inside the loops.
|
|
|
|
|
|
let query_lower = query.to_lowercase();
|
|
|
|
|
|
|
2026-02-23 23:36:38 +01:00
|
|
|
|
for file in &files {
|
|
|
|
|
|
let file_dto = FileDto::from(file.clone());
|
2026-03-07 11:23:56 +01:00
|
|
|
|
let score = compute_relevance(&file_dto.name, &query_lower);
|
2026-02-23 23:36:38 +01:00
|
|
|
|
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),
|
|
|
|
|
|
icon_special_class: get_icon_special_class(&file_dto.name, &file_dto.mime_type),
|
|
|
|
|
|
relevance_score: score,
|
|
|
|
|
|
});
|
2026-02-15 23:45:11 +01:00
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-23 23:36:38 +01:00
|
|
|
|
for folder in &folders {
|
|
|
|
|
|
let folder_dto = FolderDto::from(folder.clone());
|
2026-03-07 11:23:56 +01:00
|
|
|
|
let score = compute_relevance(&folder_dto.name, &query_lower);
|
2026-02-23 23:36:38 +01:00
|
|
|
|
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(),
|
|
|
|
|
|
icon_special_class: "folder-icon".to_string(),
|
|
|
|
|
|
relevance_score: score,
|
|
|
|
|
|
});
|
2026-02-15 23:45:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-23 23:36:38 +01:00
|
|
|
|
// Merge files + folders by relevance and truncate to the final limit
|
2026-02-15 23:45:11 +01:00
|
|
|
|
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
|
|
|
|
// ─── SearchUseCase trait implementation ──────────────────────────────────
|
|
|
|
|
|
|
2025-03-27 01:13:34 +01:00
|
|
|
|
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-21 13:17:33 -08:00
|
|
|
|
* Optimization: For non-recursive searches, uses database-level pagination
|
|
|
|
|
|
* for better performance. For recursive searches, uses the parallel approach.
|
|
|
|
|
|
*
|
2026-02-15 23:45:11 +01:00
|
|
|
|
* All processing happens server-side:
|
2026-02-21 13:17:33 -08:00
|
|
|
|
* - Database-level pagination for non-recursive searches
|
|
|
|
|
|
* - Parallel recursive traversal for recursive searches
|
2026-02-15 23:45:11 +01:00
|
|
|
|
* - 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
|
|
|
|
*/
|
2026-02-26 00:47:32 +01:00
|
|
|
|
async fn search(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
criteria: SearchCriteriaDto,
|
|
|
|
|
|
user_id: &str,
|
|
|
|
|
|
) -> Result<Arc<SearchResultsDto>> {
|
2026-02-15 23:45:11 +01:00
|
|
|
|
let start = Instant::now();
|
|
|
|
|
|
|
2026-02-21 14:48:39 -08:00
|
|
|
|
// Try to get from cache
|
2026-02-23 00:17:40 +01:00
|
|
|
|
let cache_key = Self::create_cache_key(&criteria, user_id);
|
2026-02-24 12:58:47 +01:00
|
|
|
|
if let Some(cached_results) = self.get_from_cache(cache_key).await {
|
2026-02-23 00:17:40 +01:00
|
|
|
|
return Ok(cached_results);
|
2025-03-27 01:13:34 +01:00
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-21 13:17:33 -08:00
|
|
|
|
let query = criteria.name_contains.as_deref().unwrap_or("");
|
2026-03-07 11:23:56 +01:00
|
|
|
|
// Pre-compute once — avoids N heap allocations inside enrich_file/enrich_folder.
|
|
|
|
|
|
let query_lower = query.to_lowercase();
|
2026-02-21 13:17:33 -08:00
|
|
|
|
|
|
|
|
|
|
// For non-recursive searches, use efficient database-level pagination
|
|
|
|
|
|
// This avoids loading all files into memory
|
|
|
|
|
|
if !criteria.recursive {
|
|
|
|
|
|
// Use database-level pagination
|
|
|
|
|
|
let (files, total_file_count) = self
|
|
|
|
|
|
.file_repository
|
|
|
|
|
|
.search_files_paginated(criteria.folder_id.as_deref(), &criteria, user_id)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
|
|
// Convert to DTOs and enrich with metadata
|
|
|
|
|
|
let file_dtos: Vec<FileDto> = files.into_iter().map(FileDto::from).collect();
|
|
|
|
|
|
let enriched_files: Vec<SearchFileResultDto> = file_dtos
|
|
|
|
|
|
.iter()
|
2026-03-07 11:23:56 +01:00
|
|
|
|
.map(|f| Self::enrich_file(f, &query_lower))
|
2026-02-21 13:17:33 -08:00
|
|
|
|
.collect();
|
|
|
|
|
|
|
2026-02-24 17:15:36 +01:00
|
|
|
|
// Get folders for this folder (non-recursive, filtered in SQL)
|
2026-02-21 13:17:33 -08:00
|
|
|
|
let folders = self
|
|
|
|
|
|
.folder_repository
|
2026-02-24 17:15:36 +01:00
|
|
|
|
.search_folders(
|
|
|
|
|
|
criteria.folder_id.as_deref(),
|
|
|
|
|
|
criteria.name_contains.as_deref(),
|
|
|
|
|
|
user_id,
|
|
|
|
|
|
false,
|
|
|
|
|
|
)
|
2026-02-21 13:17:33 -08:00
|
|
|
|
.await?;
|
|
|
|
|
|
|
2026-02-24 17:15:36 +01:00
|
|
|
|
let filtered_folders: Vec<FolderDto> =
|
|
|
|
|
|
folders.into_iter().map(FolderDto::from).collect();
|
2026-02-21 13:17:33 -08:00
|
|
|
|
|
|
|
|
|
|
// For folders, apply sorting and pagination in memory (usually fewer folders)
|
|
|
|
|
|
let mut enriched_folders: Vec<SearchFolderResultDto> = filtered_folders
|
|
|
|
|
|
.iter()
|
2026-03-07 11:23:56 +01:00
|
|
|
|
.map(|f| Self::enrich_folder(f, &query_lower))
|
2026-02-21 13:17:33 -08:00
|
|
|
|
.collect();
|
|
|
|
|
|
|
2026-03-02 01:30:34 +01:00
|
|
|
|
// Sort folders (cached_key avoids O(N log N) temporary String allocations)
|
2026-02-21 13:17:33 -08:00
|
|
|
|
match criteria.sort_by.as_str() {
|
|
|
|
|
|
"name" => {
|
2026-03-02 01:30:34 +01:00
|
|
|
|
enriched_folders.sort_by_cached_key(|f| f.name.to_lowercase());
|
2026-02-21 13:17:33 -08:00
|
|
|
|
}
|
|
|
|
|
|
"name_desc" => {
|
2026-03-02 01:30:34 +01:00
|
|
|
|
enriched_folders.sort_by_cached_key(|f| Reverse(f.name.to_lowercase()));
|
2026-02-21 13:17:33 -08:00
|
|
|
|
}
|
|
|
|
|
|
"date" => {
|
|
|
|
|
|
enriched_folders.sort_by(|a, b| a.modified_at.cmp(&b.modified_at));
|
|
|
|
|
|
}
|
|
|
|
|
|
"date_desc" => {
|
|
|
|
|
|
enriched_folders.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {
|
|
|
|
|
|
enriched_folders.sort_by(|a, b| b.relevance_score.cmp(&a.relevance_score));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let folder_count = enriched_folders.len();
|
|
|
|
|
|
let total_count = total_file_count + folder_count;
|
|
|
|
|
|
|
|
|
|
|
|
// Combine and paginate (folders first, then files)
|
|
|
|
|
|
let start_idx = criteria.offset.min(total_count);
|
|
|
|
|
|
let end_idx = (criteria.offset + criteria.limit).min(total_count);
|
|
|
|
|
|
|
2026-02-25 10:28:34 +01:00
|
|
|
|
let folder_start = start_idx.min(folder_count);
|
|
|
|
|
|
let folder_end = end_idx.min(folder_count);
|
|
|
|
|
|
let paginated_folders = enriched_folders[folder_start..folder_end].to_vec();
|
|
|
|
|
|
|
|
|
|
|
|
let file_start = start_idx.saturating_sub(folder_count);
|
|
|
|
|
|
let file_end = end_idx
|
|
|
|
|
|
.saturating_sub(folder_count)
|
|
|
|
|
|
.min(enriched_files.len());
|
|
|
|
|
|
let paginated_files = enriched_files[file_start..file_end].to_vec();
|
2026-02-21 13:17:33 -08:00
|
|
|
|
|
|
|
|
|
|
let elapsed_ms = start.elapsed().as_millis() as u64;
|
|
|
|
|
|
|
2026-02-25 23:47:51 +01:00
|
|
|
|
let search_results = Arc::new(SearchResultsDto::new(
|
2026-02-21 13:17:33 -08:00
|
|
|
|
paginated_files,
|
|
|
|
|
|
paginated_folders,
|
|
|
|
|
|
criteria.limit,
|
|
|
|
|
|
criteria.offset,
|
|
|
|
|
|
Some(total_count),
|
|
|
|
|
|
elapsed_ms,
|
|
|
|
|
|
criteria.sort_by.clone(),
|
2026-02-25 23:47:51 +01:00
|
|
|
|
));
|
2026-02-21 13:17:33 -08:00
|
|
|
|
|
2026-02-26 00:47:32 +01:00
|
|
|
|
self.store_in_cache(cache_key, Arc::clone(&search_results))
|
|
|
|
|
|
.await;
|
2026-02-21 13:17:33 -08:00
|
|
|
|
return Ok(search_results);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-23 00:17:40 +01:00
|
|
|
|
// ── Recursive search via ltree (single SQL query per entity type) ──
|
|
|
|
|
|
// Uses PostgreSQL ltree GiST index to find all files and folders
|
|
|
|
|
|
// in the subtree in O(1) queries, replacing the O(N) spawn-per-folder
|
|
|
|
|
|
// approach that could saturate the connection pool.
|
|
|
|
|
|
let (found_files, total_file_count) = self
|
|
|
|
|
|
.file_repository
|
|
|
|
|
|
.search_files_in_subtree(criteria.folder_id.as_deref(), &criteria, user_id)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
2026-02-24 17:15:36 +01:00
|
|
|
|
// Get folders (SQL-filtered, user-scoped, recursive when applicable)
|
|
|
|
|
|
let found_folders: Vec<Folder> = self
|
|
|
|
|
|
.folder_repository
|
|
|
|
|
|
.search_folders(
|
|
|
|
|
|
criteria.folder_id.as_deref(),
|
|
|
|
|
|
criteria.name_contains.as_deref(),
|
|
|
|
|
|
user_id,
|
|
|
|
|
|
true,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
2026-02-23 00:17:40 +01:00
|
|
|
|
|
|
|
|
|
|
// ── Convert to DTOs and enrich with server-computed metadata ──
|
|
|
|
|
|
let file_dtos: Vec<FileDto> = found_files.into_iter().map(FileDto::from).collect();
|
|
|
|
|
|
let enriched_files: Vec<SearchFileResultDto> = file_dtos
|
2026-02-15 23:45:11 +01:00
|
|
|
|
.iter()
|
2026-03-07 11:23:56 +01:00
|
|
|
|
.map(|f| Self::enrich_file(f, &query_lower))
|
2026-02-15 23:45:11 +01:00
|
|
|
|
.collect();
|
|
|
|
|
|
|
2026-02-23 00:17:40 +01:00
|
|
|
|
let folder_dtos: Vec<FolderDto> = found_folders.into_iter().map(FolderDto::from).collect();
|
|
|
|
|
|
let mut enriched_folders: Vec<SearchFolderResultDto> = folder_dtos
|
2026-02-15 23:45:11 +01:00
|
|
|
|
.iter()
|
2026-03-07 11:23:56 +01:00
|
|
|
|
.map(|f| Self::enrich_folder(f, &query_lower))
|
2026-02-15 23:45:11 +01:00
|
|
|
|
.collect();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-03-02 01:30:34 +01:00
|
|
|
|
// ── Sort folders (cached_key avoids O(N log N) temporary String allocations) ──
|
2026-02-15 23:45:11 +01:00
|
|
|
|
match criteria.sort_by.as_str() {
|
|
|
|
|
|
"name" => {
|
2026-03-02 01:30:34 +01:00
|
|
|
|
enriched_folders.sort_by_cached_key(|f| f.name.to_lowercase());
|
2026-02-15 23:45:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
"name_desc" => {
|
2026-03-02 01:30:34 +01:00
|
|
|
|
enriched_folders.sort_by_cached_key(|f| Reverse(f.name.to_lowercase()));
|
2026-02-15 23:45:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
"date" => {
|
|
|
|
|
|
enriched_folders.sort_by(|a, b| a.modified_at.cmp(&b.modified_at));
|
|
|
|
|
|
}
|
|
|
|
|
|
"date_desc" => {
|
|
|
|
|
|
enriched_folders.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {
|
2026-02-23 00:17:40 +01:00
|
|
|
|
enriched_folders.sort_by(|a, b| b.relevance_score.cmp(&a.relevance_score));
|
2026-02-15 23:45:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-23 00:17:40 +01:00
|
|
|
|
// ── Pagination (folders first, then files) ──
|
|
|
|
|
|
let folder_count = enriched_folders.len();
|
|
|
|
|
|
let total_count = total_file_count + folder_count;
|
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
|
|
|
|
|
2026-02-25 10:28:34 +01:00
|
|
|
|
let folder_start = start_idx.min(folder_count);
|
|
|
|
|
|
let folder_end = end_idx.min(folder_count);
|
|
|
|
|
|
let paginated_folders = enriched_folders[folder_start..folder_end].to_vec();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-25 10:28:34 +01:00
|
|
|
|
let file_start = start_idx.saturating_sub(folder_count);
|
|
|
|
|
|
let file_end = end_idx
|
|
|
|
|
|
.saturating_sub(folder_count)
|
|
|
|
|
|
.min(enriched_files.len());
|
|
|
|
|
|
let paginated_files = enriched_files[file_start..file_end].to_vec();
|
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;
|
|
|
|
|
|
|
2026-02-25 23:47:51 +01:00
|
|
|
|
let search_results = Arc::new(SearchResultsDto::new(
|
2025-03-27 01:13:34 +01:00
|
|
|
|
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(),
|
2026-02-25 23:47:51 +01:00
|
|
|
|
));
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-25 23:47:51 +01:00
|
|
|
|
// Store in cache — Arc::clone is ~1 ns (atomic increment)
|
2026-02-26 00:47:32 +01:00
|
|
|
|
self.store_in_cache(cache_key, Arc::clone(&search_results))
|
|
|
|
|
|
.await;
|
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<()> {
|
2026-02-22 22:37:36 +01:00
|
|
|
|
self.search_cache.invalidate_all();
|
2026-02-24 12:58:47 +01:00
|
|
|
|
self.search_cache.run_pending_tasks().await;
|
2025-03-27 01:13:34 +01:00
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-21 14:48:39 -08:00
|
|
|
|
// ─── Stub for testing ────────────────────────────────────────────────────
|
2026-02-15 23:45:11 +01:00
|
|
|
|
|
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
|
|
|
|
impl SearchUseCase for SearchServiceStub {
|
2026-02-25 10:28:34 +01:00
|
|
|
|
async fn search(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
_criteria: SearchCriteriaDto,
|
|
|
|
|
|
_user_id: &str,
|
2026-02-25 23:47:51 +01:00
|
|
|
|
) -> Result<Arc<SearchResultsDto>> {
|
|
|
|
|
|
Ok(Arc::new(SearchResultsDto::empty()))
|
2025-03-27 01:13:34 +01:00
|
|
|
|
}
|
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
|
|
|
|
}
|