use crate::Workspace;
use crate::config::CONFIG;
use crate::util::UnwrapPoison;
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::path::Path;
use std::sync::{Arc, RwLock};
use tokio::sync::OnceCell;
static REGISTRY: OnceCell<RwLock<HashMap<String, Arc<SearchEngineEntry>>>> = OnceCell::const_new();
pub fn init_global() {
let _ = REGISTRY.set(RwLock::new(HashMap::new()));
}
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,
}
pub(crate) fn get_or_init_engine(
name: &str,
path: &Path,
ephemeral: bool,
) -> Result<Arc<SearchEngineEntry>, String> {
{
let reg = registry().read().unwrap_poison();
if let Some(entry) = reg.get(name) {
return Ok(Arc::clone(entry));
}
}
let mut reg = registry().write().unwrap_poison();
if let Some(existing) = reg.get(name) {
return Ok(Arc::clone(existing));
}
let entry = Arc::new(init_engine_for_workspace(name, path, ephemeral)?);
reg.insert(name.to_string(), Arc::clone(&entry));
Ok(entry)
}
fn init_engine_for_workspace(
name: &str,
path: &Path,
ephemeral: bool,
) -> Result<SearchEngineEntry, String> {
if !path.exists() {
return Err(format!(
"Workspace directory does not exist: {}",
path.display()
));
}
let picker = SharedFilePicker::default();
let frecency = SharedFrecency::default();
let query_tracker = match open_persistent_query_tracker(name, path, ephemeral) {
Ok(qt) => qt,
Err(e) => {
tracing::warn!(
workspace_name = name,
error = %e,
"Failed to open persistent query tracker — using in-memory fallback"
);
SharedQueryTracker::default()
}
};
let options = FilePickerOptions {
base_path: path.to_string_lossy().to_string(),
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 = name,
workspace_path = %path.display(),
"Search engine created — background scan started"
);
Ok(SearchEngineEntry {
picker,
query_tracker,
})
}
fn open_persistent_query_tracker(
workspace_name: &str,
workspace_path: &Path,
ephemeral: bool,
) -> Result<SharedQueryTracker, String> {
let db_path = if ephemeral {
workspace_path.join(".queries")
} else {
CONFIG
.global_storage_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> {
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(EMPTY_INDEX_MSG.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())
}
}
pub(crate) const EMPTY_INDEX_MSG: &str = "Scan completed but file index is empty — workspace may be misconfigured or scan failed silently.";
#[must_use]
pub(crate) fn is_empty_index_error(e: &str) -> bool {
e == EMPTY_INDEX_MSG
}
pub(crate) async fn resolve_engine(
name: &str,
path: &str,
scan_error_prefix: &str,
ephemeral: bool,
) -> Result<Arc<SearchEngineEntry>, String> {
let entry = get_or_init_engine(name, Path::new(path), ephemeral)?;
ensure_scanned(&entry)
.await
.map_err(|e| format!("{scan_error_prefix}{e}"))?;
Ok(entry)
}
#[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(crate) fn remove_engine(workspace_name: &str) {
let mut reg = registry().write().unwrap_poison();
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.name, Path::new(&ws.path), false) {
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"
);
}