Files
searchi/apps/desktop/src-tauri/src/settings.rs
T

288 lines
11 KiB
Rust
Raw Normal View History

//! 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},
};
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BackgroundMaterial {
Solid,
Glass,
#[default]
Frosted,
}
fn default_background_opacity() -> u8 {
90
}
const SETTINGS_VERSION: u32 = 2;
const MAX_SETTINGS_BYTES: u64 = 128 * 1024;
fn default_automatic_indexing() -> bool {
true
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub version: u32,
#[serde(default = "default_automatic_indexing")]
pub automatic_indexing: bool,
#[serde(default)]
pub background_material: BackgroundMaterial,
#[serde(default = "default_background_opacity")]
pub background_opacity: u8,
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,
automatic_indexing: true,
background_material: BackgroundMaterial::default(),
background_opacity: default_background_opacity(),
roots: Vec::new(),
cpu_threads: 2,
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 !(40..=100).contains(&self.background_opacity) {
return Err("Background opacity must be between 40 and 100 percent.".into());
}
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 mut 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()),
};
// Version 1 applied the value independently to both workers. Reserve
// one background thread while retaining the old query allocation.
if value.version == 1 {
if !(1..=8).contains(&value.cpu_threads) {
return Err("CPU threads must be between 1 and 8.".into());
}
value.version = SETTINGS_VERSION;
value.cpu_threads = (value.cpu_threads + 1).min(8);
}
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, 2);
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());
assert_eq!(store.value.version, 2);
assert_eq!(store.value.cpu_threads, 2);
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()
);
}
}
#[cfg(test)]
mod budget_migration_tests {
use super::*;
#[test]
fn version_two_single_thread_is_explicit_and_invalid_legacy_budget_is_rejected() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.json");
fs::write(&path, r#"{"version":2,"roots":[],"cpu_threads":1}"#).unwrap();
assert_eq!(
SettingsStore::load(path.clone()).unwrap().value.cpu_threads,
1
);
fs::write(&path, r#"{"version":1,"roots":[],"cpu_threads":9}"#).unwrap();
assert!(SettingsStore::load(path.clone()).is_err());
fs::write(&path, r#"{"version":1,"roots":[],"cpu_threads":8}"#).unwrap();
assert_eq!(SettingsStore::load(path).unwrap().value.cpu_threads, 8);
}
}
#[cfg(test)]
mod appearance_tests {
use super::*;
#[test]
fn legacy_defaults_and_saved_appearance_round_trip() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.json");
fs::write(&path, r#"{"version":2,"roots":[],"cpu_threads":2}"#).unwrap();
let mut store = SettingsStore::load(path.clone()).unwrap();
assert_eq!(store.value.background_material, BackgroundMaterial::Frosted);
assert_eq!(store.value.background_opacity, 90);
let mut next = store.value.clone();
next.background_material = BackgroundMaterial::Glass;
next.background_opacity = 40;
store.save(next.clone()).unwrap();
assert_eq!(SettingsStore::load(path.clone()).unwrap().value, next);
let bytes = fs::read(&path).unwrap();
for opacity in [0, 39, 101, 255] {
let mut invalid = next.clone();
invalid.background_opacity = opacity;
assert!(store.save(invalid).is_err());
assert_eq!(store.value, next);
assert_eq!(fs::read(&path).unwrap(), bytes);
}
assert!(serde_json::from_str::<BackgroundMaterial>("\"other\"").is_err());
}
}