289 lines
11 KiB
Rust
289 lines
11 KiB
Rust
//! 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());
|
|
}
|
|
}
|