feat: integrate Google semantic search and branded desktop launcher
Core CI / core (ubuntu-latest) (push) Failing after 3m27s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 5m45s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
Core CI / core (ubuntu-latest) (push) Failing after 3m27s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 5m45s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
This commit is contained in:
1 parent
a90df15503
commit
caf6a2f82e
80 files changed
+14971
-837
No files matched your search
@@ -1,21 +1,75 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod model_download;
|
||||
mod semantic_commands;
|
||||
mod settings;
|
||||
|
||||
use searchi_core::{IndexReport, IndexStats, SearchIndex, SearchOptions, SearchResult};
|
||||
use serde::Deserialize;
|
||||
use semantic_commands::{
|
||||
hybrid_search, index_embeddings, semantic_status, setup_model, SemanticState,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsStore};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
Arc, Mutex,
|
||||
},
|
||||
};
|
||||
use tauri::{Manager, State};
|
||||
use tauri::{Emitter, Manager, State};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
struct AppState {
|
||||
database: PathBuf,
|
||||
lexical: Arc<Mutex<SearchIndex>>,
|
||||
indexing: Arc<AtomicBool>,
|
||||
settings: Arc<Mutex<SettingsStore>>,
|
||||
shortcut_status: String,
|
||||
semantic: Arc<SemanticState>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DesktopStatus {
|
||||
settings: Settings,
|
||||
indexing: bool,
|
||||
shortcut_status: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn desktop_status(state: State<'_, AppState>) -> Result<DesktopStatus, String> {
|
||||
Ok(DesktopStatus {
|
||||
settings: state
|
||||
.settings
|
||||
.lock()
|
||||
.map_err(|_| "Settings are unavailable.")?
|
||||
.value
|
||||
.clone(),
|
||||
indexing: state.indexing.load(Ordering::Acquire),
|
||||
shortcut_status: state.shortcut_status.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_cpu_threads(threads: usize, state: State<'_, AppState>) -> Result<Settings, String> {
|
||||
if state.indexing.load(Ordering::Acquire) {
|
||||
return Err("Wait for indexing to finish before changing resource settings.".into());
|
||||
}
|
||||
let mut store = state
|
||||
.settings
|
||||
.lock()
|
||||
.map_err(|_| "Settings are unavailable.")?;
|
||||
let mut next = store.value.clone();
|
||||
next.cpu_threads = threads;
|
||||
next.validate()?;
|
||||
state.semantic.reset_session()?;
|
||||
store.save(next)?;
|
||||
state
|
||||
.semantic
|
||||
.begin_automatic_setup(state.database.clone(), threads);
|
||||
Ok(store.value.clone())
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
@@ -48,12 +102,12 @@ async fn search(
|
||||
filters: Option<Filters>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<SearchResult>, String> {
|
||||
let database = state.database.clone();
|
||||
let lexical = state.lexical.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
SearchIndex::open(database)
|
||||
.and_then(|index| {
|
||||
index.search_with_options(&query, 50, &filters.unwrap_or_default().into())
|
||||
})
|
||||
lexical
|
||||
.try_lock()
|
||||
.map_err(|_| "Keyword worker is busy; try the latest query again.".to_owned())?
|
||||
.search_with_options(&query, 50, &filters.unwrap_or_default().into())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
@@ -150,11 +204,23 @@ async fn index_folder(root: String, state: State<'_, AppState>) -> Result<IndexR
|
||||
}
|
||||
let guard = IndexGuard(state.indexing.clone());
|
||||
let database = state.database.clone();
|
||||
let settings = state.settings.clone();
|
||||
let lexical = state.lexical.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _guard = guard;
|
||||
SearchIndex::open(database)
|
||||
.and_then(|mut index| index.index_folder(root))
|
||||
.map_err(|e| e.to_string())
|
||||
settings
|
||||
.lock()
|
||||
.map_err(|_| "Settings are unavailable.")?
|
||||
.add_root(std::path::Path::new(&root))?;
|
||||
let mut index = SearchIndex::open(database).map_err(|error| error.to_string())?;
|
||||
let report = index
|
||||
.index_folder(root)
|
||||
.map_err(|error| error.to_string())?;
|
||||
index.prewarm().map_err(|error| error.to_string())?;
|
||||
*lexical
|
||||
.lock()
|
||||
.map_err(|_| "Keyword engine is unavailable.")? = index;
|
||||
Ok(report)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -164,14 +230,78 @@ fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().with_handler(|app, _, event| {
|
||||
if event.state == ShortcutState::Pressed {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let result = window.show().and_then(|_| window.unminimize()).and_then(|_| window.set_focus());
|
||||
if let Err(error) = result { eprintln!("Could not focus Searchi: {error}"); }
|
||||
let _ = window.emit("focus-search", ());
|
||||
}
|
||||
}
|
||||
}).build())
|
||||
.setup(|app| {
|
||||
let directory = app.path().app_data_dir()?;
|
||||
std::fs::create_dir_all(&directory)?;
|
||||
let database = directory.join("searchi.sqlite");
|
||||
SearchIndex::open(&database)?;
|
||||
let lexical = Arc::new(Mutex::new(SearchIndex::open(&database)?));
|
||||
let warm_database = database.clone();
|
||||
let warm_target = lexical.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
// Prepare a separate reader and swap it in briefly; foreground
|
||||
// queries never wait on the long statistics-preparation lock.
|
||||
if let Ok(mut index) = SearchIndex::open(warm_database) {
|
||||
if index.prewarm().is_ok() {
|
||||
if let Ok(mut current) = warm_target.lock() { *current = index; }
|
||||
}
|
||||
}
|
||||
});
|
||||
let settings = SettingsStore::load(directory.join("settings.json")).map_err(std::io::Error::other)?;
|
||||
let development_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..");
|
||||
let development_models = development_root.join(".searchi/models/embeddinggemma2");
|
||||
let bundled_models = app.path().resource_dir()?.join("embeddinggemma2");
|
||||
let model_directory = settings.value.model_directory.clone().unwrap_or_else(|| {
|
||||
if cfg!(debug_assertions) && development_models.is_dir() {
|
||||
development_models.canonicalize().unwrap_or(development_models)
|
||||
} else if bundled_models.is_dir() {
|
||||
bundled_models
|
||||
} else {
|
||||
searchi_assets::default_model_directory().unwrap_or_else(|_| directory.join("Models"))
|
||||
}
|
||||
});
|
||||
let worker_name = if cfg!(windows) { "searchi-gemma-worker.exe" } else { "searchi-gemma-worker" };
|
||||
let development_worker = development_root.join(".searchi/runtime/cpu")
|
||||
.join(if cfg!(windows) { "Release" } else { "" }).join(worker_name);
|
||||
let worker = std::env::var_os("SEARCHI_EMBEDDING_WORKER").map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
if cfg!(debug_assertions) && development_worker.is_file() {
|
||||
development_worker.canonicalize().unwrap_or(development_worker)
|
||||
} else {
|
||||
app.path().resource_dir().unwrap_or_else(|_| directory.clone()).join(worker_name)
|
||||
}
|
||||
});
|
||||
let semantic = Arc::new(SemanticState::new(model_directory, worker));
|
||||
semantic.begin_automatic_setup(database.clone(), settings.value.cpu_threads);
|
||||
#[cfg(target_os = "macos")]
|
||||
let shortcut = "Shift+Super+Space";
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let shortcut = "Control+Shift+Space";
|
||||
let wayland = cfg!(target_os = "linux") &&
|
||||
(std::env::var("XDG_SESSION_TYPE").is_ok_and(|value| value == "wayland") || std::env::var_os("WAYLAND_DISPLAY").is_some());
|
||||
let shortcut_status = if wayland {
|
||||
"Global shortcut unavailable on Wayland. Open Searchi from your application launcher.".into()
|
||||
} else {
|
||||
match app.global_shortcut().register(shortcut) {
|
||||
Ok(()) => format!("Global shortcut: {shortcut}"),
|
||||
Err(error) => format!("Global shortcut unavailable: {error}"),
|
||||
}
|
||||
};
|
||||
app.manage(AppState {
|
||||
database,
|
||||
lexical,
|
||||
indexing: Arc::new(AtomicBool::new(false)),
|
||||
settings: Arc::new(Mutex::new(settings)),
|
||||
shortcut_status,
|
||||
semantic,
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
@@ -180,7 +310,13 @@ fn main() {
|
||||
stats,
|
||||
index_folder,
|
||||
pick_folder,
|
||||
open_result
|
||||
open_result,
|
||||
desktop_status,
|
||||
set_cpu_threads,
|
||||
semantic_status,
|
||||
setup_model,
|
||||
index_embeddings,
|
||||
hybrid_search
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("Searchi desktop could not start");
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Thin desktop adapter over the GUI-free, shared model provisioner.
|
||||
pub use searchi_assets::TOTAL_BYTES;
|
||||
use std::{path::Path, sync::atomic::AtomicU64};
|
||||
|
||||
pub fn provision(directory: &Path, progress: &AtomicU64) -> Result<(), String> {
|
||||
searchi_assets::provision(directory, progress).map_err(|error| format!("{error:#}"))
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Desktop-owned orchestration: managed model setup and persistent hybrid queries.
|
||||
use crate::{model_download, AppState, Filters};
|
||||
use searchi_core::SearchResult;
|
||||
use searchi_service::{EmbeddingReport, HybridSearch};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Mutex,
|
||||
},
|
||||
};
|
||||
use tauri::State;
|
||||
|
||||
pub struct SemanticState {
|
||||
pub model_directory: PathBuf,
|
||||
pub worker: PathBuf,
|
||||
verified: AtomicBool,
|
||||
downloading: AtomicBool,
|
||||
embedding: AtomicBool,
|
||||
querying: AtomicBool,
|
||||
warmed: AtomicBool,
|
||||
warming: AtomicBool,
|
||||
downloaded_bytes: AtomicU64,
|
||||
error: Mutex<Option<String>>,
|
||||
service: Mutex<Option<HybridSearch>>,
|
||||
}
|
||||
|
||||
impl SemanticState {
|
||||
pub fn new(model_directory: PathBuf, worker: PathBuf) -> Self {
|
||||
Self {
|
||||
model_directory,
|
||||
worker,
|
||||
verified: AtomicBool::new(false),
|
||||
downloading: AtomicBool::new(false),
|
||||
embedding: AtomicBool::new(false),
|
||||
querying: AtomicBool::new(false),
|
||||
warmed: AtomicBool::new(false),
|
||||
warming: AtomicBool::new(false),
|
||||
downloaded_bytes: AtomicU64::new(0),
|
||||
error: Mutex::new(None),
|
||||
service: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_session(&self) -> Result<(), String> {
|
||||
*self
|
||||
.service
|
||||
.try_lock()
|
||||
.map_err(|_| "Wait for the current semantic query before changing resources.")? = None;
|
||||
self.warmed.store(false, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_query_session(&self, database: PathBuf, threads: usize) -> Result<(), String> {
|
||||
self.warming.store(true, Ordering::Release);
|
||||
let _guard = WorkGuard(&self.warming);
|
||||
if !self.worker.is_file() {
|
||||
return Err(
|
||||
"The installed embedding worker is missing. Repair or reinstall Searchi.".into(),
|
||||
);
|
||||
}
|
||||
let mut service =
|
||||
HybridSearch::open(database, &self.model_directory, &self.worker, threads)
|
||||
.map_err(|error| error.to_string())?;
|
||||
service.warmup().map_err(|error| error.to_string())?;
|
||||
*self
|
||||
.service
|
||||
.lock()
|
||||
.map_err(|_| "Semantic session is unavailable.")? = Some(service);
|
||||
self.warmed.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// First-launch setup is authorized product behavior; inference remains offline.
|
||||
pub fn begin_automatic_setup(self: &std::sync::Arc<Self>, database: PathBuf, threads: usize) {
|
||||
if self
|
||||
.downloading
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let semantic = self.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _guard = WorkGuard(&semantic.downloading);
|
||||
semantic.record_error(None);
|
||||
let result =
|
||||
model_download::provision(&semantic.model_directory, &semantic.downloaded_bytes);
|
||||
semantic.verified.store(result.is_ok(), Ordering::Release);
|
||||
let result = result.and_then(|_| semantic.prepare_query_session(database, threads));
|
||||
semantic.record_error(result.err());
|
||||
});
|
||||
}
|
||||
|
||||
fn record_error(&self, value: Option<String>) {
|
||||
if let Ok(mut error) = self.error.lock() {
|
||||
*error = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SemanticStatus {
|
||||
model: &'static str,
|
||||
ready: bool,
|
||||
model_directory: String,
|
||||
worker_ready: bool,
|
||||
downloading: bool,
|
||||
embedding: bool,
|
||||
device_policy: &'static str,
|
||||
error: Option<String>,
|
||||
downloaded_bytes: u64,
|
||||
total_bytes: u64,
|
||||
generation: Option<i64>,
|
||||
chunks: usize,
|
||||
stale: bool,
|
||||
pending: usize,
|
||||
warming: bool,
|
||||
worker_info: Option<searchi_service::WorkerInfo>,
|
||||
}
|
||||
|
||||
struct WorkGuard<'a>(&'a AtomicBool);
|
||||
impl Drop for WorkGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn semantic_status(state: State<'_, AppState>) -> Result<SemanticStatus, String> {
|
||||
let semantic = &state.semantic;
|
||||
let worker_ready = semantic.worker.is_file();
|
||||
let index =
|
||||
HybridSearch::embedding_status(&state.database).map_err(|error| error.to_string())?;
|
||||
Ok(SemanticStatus {
|
||||
model: "Google EmbeddingGemma 2",
|
||||
ready: worker_ready && semantic.warmed.load(Ordering::Acquire),
|
||||
model_directory: semantic.model_directory.to_string_lossy().into_owned(),
|
||||
worker_ready,
|
||||
downloading: semantic.downloading.load(Ordering::Acquire),
|
||||
embedding: semantic.embedding.load(Ordering::Acquire),
|
||||
device_policy: if std::env::var("SEARCHI_FORCE_CPU").as_deref() == Ok("1") {
|
||||
"cpu"
|
||||
} else {
|
||||
"auto"
|
||||
},
|
||||
error: semantic.error.lock().ok().and_then(|value| value.clone()),
|
||||
downloaded_bytes: semantic.downloaded_bytes.load(Ordering::Acquire),
|
||||
total_bytes: model_download::TOTAL_BYTES,
|
||||
generation: index.generation,
|
||||
chunks: index.chunks,
|
||||
stale: index.stale,
|
||||
pending: index.pending,
|
||||
warming: semantic.warming.load(Ordering::Acquire),
|
||||
worker_info: semantic
|
||||
.service
|
||||
.try_lock()
|
||||
.ok()
|
||||
.and_then(|service| service.as_ref().and_then(HybridSearch::worker_info)),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn setup_model(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let semantic = state.semantic.clone();
|
||||
let database = state.database.clone();
|
||||
let threads = state
|
||||
.settings
|
||||
.lock()
|
||||
.map_err(|_| "Settings are unavailable.")?
|
||||
.value
|
||||
.cpu_threads;
|
||||
if semantic
|
||||
.downloading
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
return Err("Google model setup is already running.".into());
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _guard = WorkGuard(&semantic.downloading);
|
||||
semantic.record_error(None);
|
||||
let result =
|
||||
model_download::provision(&semantic.model_directory, &semantic.downloaded_bytes);
|
||||
semantic.verified.store(result.is_ok(), Ordering::Release);
|
||||
let result = result.and_then(|_| semantic.prepare_query_session(database, threads));
|
||||
semantic.record_error(result.as_ref().err().cloned());
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn index_embeddings(state: State<'_, AppState>) -> Result<EmbeddingReport, String> {
|
||||
let semantic = state.semantic.clone();
|
||||
if !semantic.verified.load(Ordering::Acquire) || !semantic.worker.is_file() {
|
||||
return Err("Set up the Google model and native worker first.".into());
|
||||
}
|
||||
let threads = state
|
||||
.settings
|
||||
.lock()
|
||||
.map_err(|_| "Settings are unavailable.")?
|
||||
.value
|
||||
.cpu_threads;
|
||||
if semantic
|
||||
.embedding
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
return Err("Semantic indexing is already running.".into());
|
||||
}
|
||||
let database = state.database.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _guard = WorkGuard(&semantic.embedding);
|
||||
semantic.record_error(None);
|
||||
// Indexing uses its own isolated worker; interactive queries keep their own session.
|
||||
let result = HybridSearch::open(
|
||||
database,
|
||||
&semantic.model_directory,
|
||||
&semantic.worker,
|
||||
threads,
|
||||
)
|
||||
.and_then(|mut service| service.index_embeddings())
|
||||
.map_err(|error| error.to_string());
|
||||
semantic.record_error(result.as_ref().err().cloned());
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hybrid_search(
|
||||
query: String,
|
||||
filters: Option<Filters>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<SearchResult>, String> {
|
||||
let semantic = state.semantic.clone();
|
||||
if !semantic.warmed.load(Ordering::Acquire) {
|
||||
return Err(
|
||||
"Semantic search is preparing in the background; keyword results are available.".into(),
|
||||
);
|
||||
}
|
||||
let database = state.database.clone();
|
||||
let threads = state
|
||||
.settings
|
||||
.lock()
|
||||
.map_err(|_| "Settings are unavailable.")?
|
||||
.value
|
||||
.cpu_threads;
|
||||
if semantic
|
||||
.querying
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
return Err("Semantic worker is busy; keyword results remain available.".into());
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _guard = WorkGuard(&semantic.querying);
|
||||
let mut cached = semantic
|
||||
.service
|
||||
.lock()
|
||||
.map_err(|_| "Semantic session is unavailable.")?;
|
||||
if cached.is_none() {
|
||||
*cached = Some(
|
||||
HybridSearch::open(
|
||||
database,
|
||||
&semantic.model_directory,
|
||||
&semantic.worker,
|
||||
threads,
|
||||
)
|
||||
.map_err(|error| error.to_string())?,
|
||||
);
|
||||
}
|
||||
cached
|
||||
.as_mut()
|
||||
.ok_or("Semantic session is unavailable.")?
|
||||
.search(&query, 50, &filters.unwrap_or_default().into())
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Versioned local settings. Writes replace the destination atomically; failed
|
||||
//! saves leave both the previous file and in-memory settings intact.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs,
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
const SETTINGS_VERSION: u32 = 1;
|
||||
const MAX_SETTINGS_BYTES: u64 = 128 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub version: u32,
|
||||
pub roots: Vec<PathBuf>,
|
||||
pub cpu_threads: usize,
|
||||
/// Managed local Google EmbeddingGemma2 artifacts. Older settings default
|
||||
/// to unconfigured; model acquisition is an explicit desktop action.
|
||||
#[serde(default)]
|
||||
pub model_directory: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: SETTINGS_VERSION,
|
||||
roots: Vec::new(),
|
||||
cpu_threads: 1,
|
||||
model_directory: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.version != SETTINGS_VERSION {
|
||||
return Err(format!("Unsupported settings version {}", self.version));
|
||||
}
|
||||
if !(1..=8).contains(&self.cpu_threads) {
|
||||
return Err("CPU threads must be between 1 and 8.".into());
|
||||
}
|
||||
if self.roots.len() > 64 {
|
||||
return Err("At most 64 indexed folders are supported.".into());
|
||||
}
|
||||
if let Some(directory) = &self.model_directory {
|
||||
if !directory.is_absolute() || directory.to_string_lossy().len() > 32768 {
|
||||
return Err("Model directory must be an absolute bounded path.".into());
|
||||
}
|
||||
}
|
||||
for (i, root) in self.roots.iter().enumerate() {
|
||||
if !root.is_absolute() {
|
||||
return Err("Indexed folder paths must be absolute.".into());
|
||||
}
|
||||
if root.to_string_lossy().len() > 32768 {
|
||||
return Err("Indexed folder path is too long.".into());
|
||||
}
|
||||
if self.roots[..i].contains(root) {
|
||||
return Err("Indexed folder paths must be unique.".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SettingsStore {
|
||||
path: PathBuf,
|
||||
pub value: Settings,
|
||||
}
|
||||
|
||||
impl SettingsStore {
|
||||
pub fn load(path: PathBuf) -> Result<Self, String> {
|
||||
let value = match fs::metadata(&path) {
|
||||
Ok(metadata) => {
|
||||
if metadata.len() > MAX_SETTINGS_BYTES {
|
||||
return Err("Settings file exceeds the size limit.".into());
|
||||
}
|
||||
serde_json::from_slice(&fs::read(&path).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| format!("Invalid settings file: {e}"))?
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Settings::default(),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
let store = Self { path, value };
|
||||
store.value.validate()?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub fn save(&mut self, next: Settings) -> Result<(), String> {
|
||||
next.validate()?;
|
||||
let parent = self.path.parent().ok_or("Settings path has no parent.")?;
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
|
||||
let bytes = serde_json::to_vec_pretty(&next).map_err(|e| e.to_string())?;
|
||||
if bytes.len() as u64 > MAX_SETTINGS_BYTES {
|
||||
return Err("Settings exceed the size limit.".into());
|
||||
}
|
||||
temporary.write_all(&bytes).map_err(|e| e.to_string())?;
|
||||
temporary.as_file().sync_all().map_err(|e| e.to_string())?;
|
||||
temporary.persist(&self.path).map_err(|e| e.to_string())?;
|
||||
self.value = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_root(&mut self, root: &Path) -> Result<(), String> {
|
||||
let canonical = fs::canonicalize(root).map_err(|e| e.to_string())?;
|
||||
if !canonical.is_dir() {
|
||||
return Err("Indexed folder must be a directory.".into());
|
||||
}
|
||||
let mut next = self.value.clone();
|
||||
if !next.roots.contains(&canonical) {
|
||||
next.roots.push(canonical);
|
||||
self.save(next)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_round_trip_and_atomic_replacement() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("settings.json");
|
||||
let mut store = SettingsStore::load(path.clone()).unwrap();
|
||||
assert_eq!(store.value.cpu_threads, 1);
|
||||
store.add_root(directory.path()).unwrap();
|
||||
store.add_root(directory.path()).unwrap();
|
||||
let mut next = store.value.clone();
|
||||
next.cpu_threads = 2;
|
||||
store.save(next.clone()).unwrap();
|
||||
assert_eq!(SettingsStore::load(path).unwrap().value, next);
|
||||
assert_eq!(store.value.roots.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_save_preserves_previous_file_and_memory() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("settings.json");
|
||||
let mut store = SettingsStore::load(path.clone()).unwrap();
|
||||
store.save(Settings::default()).unwrap();
|
||||
let original = fs::read(&path).unwrap();
|
||||
let mut invalid = store.value.clone();
|
||||
invalid.cpu_threads = 0;
|
||||
assert!(store.save(invalid).is_err());
|
||||
assert_eq!(store.value, Settings::default());
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
let mut invalid = store.value.clone();
|
||||
invalid.roots.push(PathBuf::from("relative"));
|
||||
assert!(store.save(invalid).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_corruption_unknown_version_and_excessive_threads() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("settings.json");
|
||||
fs::write(&path, "broken").unwrap();
|
||||
assert!(SettingsStore::load(path.clone()).is_err());
|
||||
fs::write(&path, r#"{"version":99,"roots":[],"cpu_threads":1}"#).unwrap();
|
||||
assert!(SettingsStore::load(path).is_err());
|
||||
let invalid = Settings {
|
||||
cpu_threads: 9,
|
||||
..Settings::default()
|
||||
};
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_older_settings_with_unconfigured_google_model() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("settings.json");
|
||||
fs::write(&path, r#"{"version":1,"roots":[],"cpu_threads":1}"#).unwrap();
|
||||
let store = SettingsStore::load(path).unwrap();
|
||||
assert!(store.value.model_directory.is_none());
|
||||
let invalid = Settings {
|
||||
model_directory: Some(PathBuf::from("relative")),
|
||||
..Settings::default()
|
||||
};
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_save_leaves_existing_settings_readable() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("settings.json");
|
||||
let mut store = SettingsStore::load(path.clone()).unwrap();
|
||||
store.save(Settings::default()).unwrap();
|
||||
let mut oversized = store.value.clone();
|
||||
oversized.roots = (0..64)
|
||||
.map(|i| directory.path().join(format!("{i}{}", "x".repeat(3000))))
|
||||
.collect();
|
||||
assert!(store.save(oversized).is_err());
|
||||
assert_eq!(
|
||||
SettingsStore::load(path).unwrap().value,
|
||||
Settings::default()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user