//! 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, 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, } 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 { 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() ); } }