use crate::Workspace;
use crate::config::CONFIG;
use fff_search::file_picker::{FFFMode, FilePickerOptions};
use fff_search::shared::{SharedFilePicker, SharedFrecency, SharedQueryTracker};
use fff_search::{FilePicker, QueryTracker};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
use tokio::sync::OnceCell;
static REGISTRY: OnceCell<RwLock<HashMap<String, Arc<SearchEngineEntry>>>> = OnceCell::const_new();
pub fn init_global() {
REGISTRY
.set(RwLock::new(HashMap::new()))
.expect("search engine registry already initialized");
}
fn registry() -> &'static RwLock<HashMap<String, Arc<SearchEngineEntry>>> {
REGISTRY
.get()
.expect("search engine registry not initialized — call search_engine::init_global()")
}
#[must_use]
pub(crate) fn registry_initialized() -> bool {
REGISTRY.get().is_some()
}
#[derive(Debug)]
pub(crate) struct SearchEngineEntry {
pub picker: SharedFilePicker,
pub query_tracker: SharedQueryTracker,
scan_initiated: AtomicBool,
}
pub(crate) fn get_or_init_engine(ws: &Workspace) -> Result<Arc<SearchEngineEntry>, String> {
{
let reg = registry()
.read()
.map_err(|e| format!("registry lock poisoned: {e}"))?;
if let Some(entry) = reg.get(&ws.name) {
return Ok(Arc::clone(entry));
}
}
let entry = init_engine_for_workspace(ws)?;
let mut reg = registry()
.write()
.map_err(|e| format!("registry lock poisoned: {e}"))?;
if let Some(existing) = reg.get(&ws.name) {
return Ok(Arc::clone(existing));
}
let entry = Arc::new(entry);
reg.insert(ws.name.clone(), Arc::clone(&entry));
Ok(entry)
}
fn init_engine_for_workspace(ws: &Workspace) -> Result<SearchEngineEntry, String> {
let workspace_path = std::path::Path::new(&ws.path);
if !workspace_path.exists() {
return Err(format!(
"Workspace directory does not exist: {}",
workspace_path.display()
));
}
let picker = SharedFilePicker::default();
let frecency = SharedFrecency::default();
let query_tracker = match open_persistent_query_tracker(&ws.name) {
Ok(qt) => qt,
Err(e) => {
tracing::warn!(
workspace_name = ws.name,
error = %e,
"Failed to open persistent query tracker — using in-memory fallback"
);
SharedQueryTracker::default()
}
};
let options = FilePickerOptions {
base_path: ws.path.clone(),
enable_mmap_cache: false,
enable_content_indexing: true,
mode: FFFMode::Ai,
watch: true,
follow_symlinks: false,
enable_fs_root_scanning: false,
enable_home_dir_scanning: false,
cache_budget: None,
};
FilePicker::new_with_shared_state(picker.clone(), frecency, options)
.map_err(|e| format!("Failed to create search engine: {e}"))?;
tracing::info!(
workspace_name = ws.name,
workspace_path = %ws.path,
"Search engine created — background scan started"
);
Ok(SearchEngineEntry {
picker,
query_tracker,
scan_initiated: AtomicBool::new(true),
})
}
fn open_persistent_query_tracker(workspace_name: &str) -> Result<SharedQueryTracker, String> {
let root = CONFIG.global_storage_root();
let db_path = root.join("search").join(workspace_name).join("queries");
std::fs::create_dir_all(&db_path).map_err(|e| {
format!(
"Failed to create query tracker dir {}: {e}",
db_path.display()
)
})?;
let tracker = QueryTracker::open(&db_path)
.map_err(|e| format!("Failed to open QueryTracker at {}: {e}", db_path.display()))?;
let shared = SharedQueryTracker::default();
shared
.init(tracker)
.map_err(|e| format!("Failed to init shared query tracker: {e}"))?;
Ok(shared)
}
pub(crate) async fn ensure_scanned(entry: &SearchEngineEntry) -> Result<(), String> {
if !entry.scan_initiated.load(Ordering::Acquire) {
return Ok(());
}
let picker = entry.picker.clone();
let scanned = tokio::task::spawn_blocking(move || {
picker.wait_for_scan(std::time::Duration::from_secs(30))
})
.await
.map_err(|e| format!("spawn_blocking join error: {e}"))?;
if scanned {
let guard = entry
.picker
.read()
.map_err(|e| format!("Failed to read picker state: {e}"))?;
let is_empty = match &*guard {
Some(picker) => picker.live_file_count() == 0,
None => true,
};
drop(guard);
if is_empty {
return Err(
"Scan completed but file index is empty — workspace may be misconfigured \
or scan failed silently."
.to_string(),
);
}
Ok(())
} else {
Err("Search engine scan has not completed within 30 seconds. \
The workspace may be too large or the filesystem is slow. \
Try searching again in a moment."
.to_string())
}
}
#[must_use]
pub(crate) fn get_engine_if_exists(ws: &Workspace) -> Option<Arc<SearchEngineEntry>> {
let reg = REGISTRY.get()?.read().ok()?;
reg.get(&ws.name).cloned()
}
pub struct EngineHandle {
pub picker: SharedFilePicker,
pub query_tracker: SharedQueryTracker,
_entry: Arc<SearchEngineEntry>,
}
impl EngineHandle {
pub(crate) fn new(entry: Arc<SearchEngineEntry>) -> Self {
Self {
picker: entry.picker.clone(),
query_tracker: entry.query_tracker.clone(),
_entry: entry,
}
}
}
pub fn remove_engine(workspace_name: &str) {
let mut reg = match registry().write() {
Ok(r) => r,
Err(e) => {
tracing::error!(
workspace_name,
"registry lock poisoned while removing engine: {e}"
);
return;
}
};
if let Some(entry) = reg.remove(workspace_name) {
drop(entry); tracing::info!(workspace_name, "Search engine removed from registry");
}
}
pub async fn init_all_engines() {
let workspaces = match crate::workspace::store().list().await {
Ok(wss) => wss,
Err(e) => {
tracing::error!(error = %e, "Failed to list workspaces for eager scan");
return;
}
};
for ws in &workspaces {
match get_or_init_engine(ws) {
Ok(_) => { }
Err(e) => {
tracing::warn!(
workspace_name = ws.name,
workspace_path = %ws.path,
error = %e,
"Failed to initialize search engine for workspace"
);
}
}
}
tracing::info!(
workspace_count = workspaces.len(),
"Eager search engine initialization complete"
);
}