//! Process-wide settings model: colour scheme, CSS entries, and profiles (ticket 049 folded //! profile storage inline here instead of a separate `profiles.json`), persisted as JSON. use std::collections::hash_map::DefaultHasher; use std::fmt; use std::fs; use std::hash::{Hash, Hasher}; use std::io; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; use directories::ProjectDirs; use serde::{Deserialize, Serialize}; use crate::alerts::AlertRule; use crate::profile::Profile; /// Shared, process-wide settings handle. Cheap to clone. pub type SettingsHandle = Arc>; /// User's preferred colour scheme for the webapp. /// /// `Dark` accepts the legacy `"grey"` serde value (ticket 004: "grey" was a /// near-duplicate of "dark" and has been removed as a selectable option) so a /// settings file saved before that change still loads cleanly instead of /// falling back to `System`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum ColorScheme { Light, #[serde(alias = "grey")] Dark, Red, Green, Blue, Yellow, Pink, Orange, #[default] System, } /// A single CSS entry: content ottyl stores itself ("imported") or a reference to an external /// path/URL ("linked", not deletable via the API). Every entry is always a selectable /// colour-scheme theme (ticket 020 removed the "use as theme"/always-on-overlay distinction); /// an entry persisted before that change with a stored `is_theme: false` still loads fine (the /// field is simply ignored) and becomes selectable like any other entry. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum CssEntry { Imported { id: String, filename: String }, Linked { path_or_url: String }, } /// Legacy on-disk shape of the (pre-049) `Profiles` pointer struct: only names/active-profile/dir /// pointers, with the actual profile content living in a separate `profiles.json`. Kept only so an /// old `settings.json` still parses; see `ProfilesOnDisk` and `Settings::migrate_legacy_profiles_file`. #[derive(Debug, Clone, Deserialize, Default)] struct LegacyProfilesPointer { // `names` is intentionally not captured: it was always redundant with the legacy // `profiles.json`'s actual profile list, which is what migration reads instead. #[serde(default)] active_profile: Option, /// Directory (relative to the settings file's directory, or absolute) the legacy `profiles.json` /// lived under. Every version of this codebase ever wrote `"profiles"` here (no API ever let a /// user change it), so migration doesn't bother re-deriving a custom value from this field. #[serde(default)] #[allow(dead_code)] profiles_dir: Option, } /// On-disk shape of the `profiles` field: either the new inline `Vec` (ticket 049) or the /// old pointer-struct shape, so a `settings.json` written before this ticket still deserializes. #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] enum ProfilesOnDisk { Inline(Vec), Legacy(LegacyProfilesPointer), } impl Default for ProfilesOnDisk { fn default() -> Self { ProfilesOnDisk::Inline(Vec::new()) } } /// Raw on-disk shape of `Settings`, used only to support deserializing an old `settings.json` whose /// `profiles` field was the pointer struct and had no top-level `active_profile` — see `Settings`'s /// `#[serde(from = ...)]`. #[derive(Debug, Clone, Deserialize)] struct SettingsOnDisk { color_scheme: ColorScheme, #[serde(default)] css_entries: Vec, #[serde(default)] custom_theme_id: Option, #[serde(default)] profiles: ProfilesOnDisk, #[serde(default)] active_profile: Option, #[serde(default)] max_logs: Option, #[serde(default)] alert_rules: Vec, #[serde(default)] ntfy_topic: Option, #[serde(default)] ntfy_server: Option, } impl From for Settings { fn from(on_disk: SettingsOnDisk) -> Self { let (profiles, legacy_active_profile) = match on_disk.profiles { ProfilesOnDisk::Inline(profiles) => (profiles, None), ProfilesOnDisk::Legacy(pointer) => (Vec::new(), pointer.active_profile), }; Settings { color_scheme: on_disk.color_scheme, css_entries: on_disk.css_entries, custom_theme_id: on_disk.custom_theme_id, profiles, active_profile: on_disk.active_profile.or(legacy_active_profile), max_logs: on_disk.max_logs, alert_rules: on_disk.alert_rules, ntfy_topic: on_disk.ntfy_topic, ntfy_server: on_disk.ntfy_server, } } } /// The single, process-wide settings instance. Only `PartialEq` (not `Eq`): `alert_rules` carries `f64` fields, which aren't `Eq`. /// /// `profiles`/`active_profile` used to be a separate `Profiles` pointer struct referencing a /// standalone `profiles.json` (see `crate::profile::ProfileStore`, removed by ticket 049); profile /// data now lives inline here, as the single source of truth. `Deserialize` goes through /// `SettingsOnDisk` so a `settings.json` written before this ticket still loads (see /// `migrate_legacy_profiles_file`, which then imports the old `profiles.json`, if any, on `load`). #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(from = "SettingsOnDisk")] pub struct Settings { pub color_scheme: ColorScheme, pub css_entries: Vec, /// Id of the selected custom theme entry, if any; `None` means `color_scheme` alone determines the active palette. #[serde(default)] pub custom_theme_id: Option, /// Every known profile, inline (ticket 049). pub profiles: Vec, /// Name of the currently active profile, if any. pub active_profile: Option, /// Maximum number of ingested logs kept in memory; `None` means unlimited. #[serde(default)] pub max_logs: Option, /// Alert rule definitions. #[serde(default)] pub alert_rules: Vec, /// Ntfy topic every alert rule delivers to; `None` until configured in Settings. #[serde(default)] pub ntfy_topic: Option, /// Ntfy server override; `None` means `https://ntfy.sh`. #[serde(default)] pub ntfy_server: Option, } /// Computes the default settings file path (`/settings.json`) from OS-conventional config directories; `None` if none could be determined. pub fn default_settings_path() -> Option { ProjectDirs::from("", "", "ottyl").map(|dirs| dirs.config_dir().join("settings.json")) } impl Settings { /// Loads settings from `path`, creating and persisting defaults if missing. Any I/O or parse error on an existing file is logged and treated as defaults, without overwriting the unreadable file. Also runs the one-time legacy `profiles.json` migration (see `migrate_legacy_profiles_file`). pub fn load(path: &Path) -> Settings { match fs::read_to_string(path) { Ok(contents) => match serde_json::from_str::(&contents) { Ok(mut settings) => { settings.migrate_legacy_profiles_file(path); settings.migrate_legacy_profile_shapes(path); settings } Err(err) => { eprintln!( "warning: failed to parse settings file at {}: {err}; using defaults", path.display() ); Settings::default() } }, Err(err) if err.kind() == io::ErrorKind::NotFound => { let mut settings = Settings::default(); settings.migrate_legacy_profiles_file(path); if let Err(save_err) = settings.save(path) { eprintln!( "warning: failed to create default settings file at {}: {save_err}", path.display() ); } settings } Err(err) => { eprintln!( "warning: failed to read settings file at {}: {err}; using defaults", path.display() ); Settings::default() } } } /// Persists these settings to `path` as pretty-printed JSON, creating any missing parent directories first. pub fn save(&self, path: &Path) -> io::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let contents = serde_json::to_string_pretty(self) .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; fs::write(path, contents) } /// One-time migration (ticket 049): if `self.profiles` is empty (a fresh `Settings`, or one /// parsed from a pre-049 `settings.json` whose profiles lived in a separate file) and a legacy /// `/profiles/profiles.json` exists, reads it in, merges it into /// `self.profiles`, persists `self`, and — only once that save succeeds — renames the old file /// (`.migrated` suffix) so it isn't re-imported on a later load. Every prior version of this /// codebase always used the fixed `"profiles"` subdirectory name (no API ever let it be /// reconfigured), so that's the only location checked. Best-effort: any failure is logged and /// left for a future load to retry rather than panicking. fn migrate_legacy_profiles_file(&mut self, settings_path: &Path) { if !self.profiles.is_empty() { return; } let legacy_path = settings_path .parent() .map(|parent| parent.join("profiles")) .unwrap_or_else(|| PathBuf::from("profiles")) .join("profiles.json"); let Some(profiles) = crate::profile::load_legacy_profile_store(&legacy_path) else { return; }; self.profiles = profiles; if let Err(err) = self.save(settings_path) { eprintln!( "warning: failed to persist profiles migrated from {}: {err}", legacy_path.display() ); return; } let migrated_path = legacy_path.with_extension("json.migrated"); if let Err(err) = fs::rename(&legacy_path, &migrated_path) { eprintln!( "warning: migrated profiles from {} into settings.json, but failed to rename the old file: {err}", legacy_path.display() ); } } /// Migrates any pre-037/pre-038 metric or param shapes still present in `self.profiles` into /// their current form (see `crate::profile::migrate_legacy_profile_shapes`), persisting `self` /// if anything changed. Runs on every load — previously done inside the now-removed /// `ProfileStore::load` (ticket 049 folded profile storage into `settings.json`) — so a profile /// saved by an older version of ottyl, whether loaded inline here or just merged in by /// `migrate_legacy_profiles_file`, still gets migrated forward. fn migrate_legacy_profile_shapes(&mut self, settings_path: &Path) { if crate::profile::migrate_legacy_profile_shapes(&mut self.profiles) && let Err(err) = self.save(settings_path) { eprintln!( "warning: failed to persist migrated profile shapes to {}: {err}", settings_path.display() ); } } } /// Error returned by CSS-entry mutations and lookups. #[derive(Debug)] pub enum CssError { /// No entry with the given id exists. NotFound, /// The entry is a `Linked` entry, not `Imported` — ottyl doesn't own a linked entry's content, so there's nothing to delete or serve back. NotImported, /// Underlying I/O failure: writing/reading/removing the CSS file, or persisting `settings.json`. Io(io::Error), } impl fmt::Display for CssError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { CssError::NotFound => write!(f, "no CSS entry with that id"), CssError::NotImported => write!(f, "CSS entry is linked, not imported"), CssError::Io(err) => write!(f, "I/O error: {err}"), } } } impl std::error::Error for CssError {} impl From for CssError { fn from(err: io::Error) -> Self { CssError::Io(err) } } /// Computes the id used to reference a `CssEntry` over the API: the stored id for `Imported`, or a hash of `path_or_url` for `Linked` (which has no stored id), so the frontend can key both the same way. pub fn css_entry_id(entry: &CssEntry) -> String { match entry { CssEntry::Imported { id, .. } => id.clone(), CssEntry::Linked { path_or_url, .. } => linked_entry_id(path_or_url), } } fn linked_entry_id(path_or_url: &str) -> String { let mut hasher = DefaultHasher::new(); path_or_url.hash(&mut hasher); format!("linked-{:016x}", hasher.finish()) } /// Generates an id for a newly-imported CSS entry by hashing filename, content, and time, avoiding a `uuid` dependency for what's otherwise an opaque string. fn generate_imported_css_id(filename: &str, content: &str) -> String { let mut hasher = DefaultHasher::new(); filename.hash(&mut hasher); content.hash(&mut hasher); let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); nanos.hash(&mut hasher); format!("{:016x}", hasher.finish()) } fn imported_css_file_path(css_dir: &Path, id: &str) -> PathBuf { css_dir.join(format!("{id}.css")) } impl Settings { /// Imports raw CSS `content`: writes it to `/.css`, records a `CssEntry::Imported` in `css_entries`, and persists settings. Returns the generated id. `css_dir` is expected to be the settings file's parent directory. pub fn import_css( &mut self, settings_path: &Path, css_dir: &Path, filename: String, content: &str, ) -> Result { fs::create_dir_all(css_dir)?; let id = generate_imported_css_id(&filename, content); fs::write(imported_css_file_path(css_dir, &id), content)?; self.css_entries.push(CssEntry::Imported { id: id.clone(), filename, }); self.save(settings_path)?; Ok(id) } /// Registers a reference to an external CSS path/URL without copying its content, and persists settings. pub fn link_css(&mut self, settings_path: &Path, path_or_url: String) -> Result<(), CssError> { self.css_entries.push(CssEntry::Linked { path_or_url }); self.save(settings_path)?; Ok(()) } /// Removes an imported CSS entry identified by `id`: deletes its file under `css_dir`, drops it from `css_entries`, and persists settings. Fails with `NotFound` or `NotImported` (linked entries aren't deletable this way). pub fn remove_css( &mut self, settings_path: &Path, css_dir: &Path, id: &str, ) -> Result<(), CssError> { let index = self .css_entries .iter() .position(|entry| css_entry_id(entry) == id); match index.map(|i| &self.css_entries[i]) { None => Err(CssError::NotFound), Some(CssEntry::Linked { .. }) => Err(CssError::NotImported), Some(CssEntry::Imported { .. }) => { let idx = index.unwrap(); self.css_entries.remove(idx); // Best-effort: the settings entry is already removed even if the file is gone. let _ = fs::remove_file(imported_css_file_path(css_dir, id)); self.save(settings_path)?; Ok(()) } } } /// Reads back the raw content of an imported CSS entry identified by `id`. Fails with `NotFound` or `NotImported` (linked entries have no local content — the frontend fetches `path_or_url` directly). pub fn css_content(&self, css_dir: &Path, id: &str) -> Result { match self.css_entries.iter().find(|entry| css_entry_id(entry) == id) { None => Err(CssError::NotFound), Some(CssEntry::Linked { .. }) => Err(CssError::NotImported), Some(CssEntry::Imported { .. }) => { Ok(fs::read_to_string(imported_css_file_path(css_dir, id))?) } } } } #[cfg(test)] mod tests { use super::*; #[test] fn default_settings_path_ends_with_settings_json() { // Can't assert the exact OS path without touching real config dirs, so just check the shape. if let Some(path) = default_settings_path() { assert_eq!(path.file_name().unwrap(), "settings.json"); assert!(path.parent().is_some()); } } #[test] fn load_creates_default_when_missing() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("nested").join("settings.json"); assert!(!path.exists()); let settings = Settings::load(&path); assert_eq!(settings, Settings::default()); assert!(path.exists(), "load() should persist a default file"); let reloaded = Settings::load(&path); assert_eq!(reloaded, settings); } #[test] fn legacy_grey_color_scheme_deserializes_as_dark() { let settings: Settings = serde_json::from_str( r#"{"color_scheme":"grey","css_entries":[],"custom_theme_id":null,"profiles":{"names":[],"active_profile":null,"profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#, ) .unwrap(); assert_eq!(settings.color_scheme, ColorScheme::Dark); } /// A `settings.json` written before ticket 049 (nested `Profiles` pointer struct, no top-level /// `active_profile`) still parses: the pointer's `active_profile` carries over, and `profiles` /// starts empty (the real content lived in a separate `profiles.json`, migrated separately by /// `Settings::load`, not by plain deserialization). #[test] fn legacy_profiles_pointer_shape_deserializes_with_empty_inline_profiles() { let settings: Settings = serde_json::from_str( r#"{"color_scheme":"dark","css_entries":[],"custom_theme_id":null,"profiles":{"names":["web"],"active_profile":"web","profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#, ) .unwrap(); assert!(settings.profiles.is_empty()); assert_eq!(settings.active_profile, Some("web".to_string())); } #[test] fn round_trips_through_save_and_load() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("settings.json"); let mut settings = Settings::default(); settings.color_scheme = ColorScheme::Dark; settings.css_entries.push(CssEntry::Imported { id: "abc123".to_string(), filename: "theme.css".to_string(), }); settings.css_entries.push(CssEntry::Linked { path_or_url: "https://example.com/style.css".to_string(), }); settings.profiles.push(crate::profile::Profile::new("default".to_string())); settings.active_profile = Some("default".to_string()); settings.alert_rules.push(crate::alerts::AlertRule { id: "rule1".to_string(), name: "error spike".to_string(), enabled: true, condition: crate::alerts::AlertCondition::Immediate { query: r#"regex("error")"#.to_string(), }, }); settings.ntfy_topic = Some("alerts".to_string()); settings.save(&path).unwrap(); let loaded = Settings::load(&path); assert_eq!(loaded, settings); } #[test] fn save_creates_parent_directories() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("a").join("b").join("settings.json"); let settings = Settings::default(); settings.save(&path).unwrap(); assert!(path.exists()); } #[test] fn import_css_persists_file_and_entry_across_reload() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let css_dir = dir.path().to_path_buf(); let mut settings = Settings::load(&settings_path); let id = settings .import_css( &settings_path, &css_dir, "theme.css".to_string(), "body { color: red; }", ) .unwrap(); let css_file = css_dir.join(format!("{id}.css")); assert!(css_file.exists()); assert_eq!(fs::read_to_string(&css_file).unwrap(), "body { color: red; }"); // Drop and reload settings from disk (simulates a restart). drop(settings); let reloaded = Settings::load(&settings_path); assert!(reloaded.css_entries.iter().any(|entry| matches!( entry, CssEntry::Imported { id: entry_id, filename, .. } if entry_id == &id && filename == "theme.css" ))); assert!(css_file.exists()); assert_eq!( reloaded.css_content(&css_dir, &id).unwrap(), "body { color: red; }" ); } #[test] fn remove_css_deletes_file_and_entry() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let css_dir = dir.path().to_path_buf(); let mut settings = Settings::load(&settings_path); let id = settings .import_css(&settings_path, &css_dir, "a.css".to_string(), "a{}") .unwrap(); let css_file = css_dir.join(format!("{id}.css")); assert!(css_file.exists()); settings.remove_css(&settings_path, &css_dir, &id).unwrap(); assert!(!css_file.exists()); assert!(!settings.css_entries.iter().any(|e| css_entry_id(e) == id)); let reloaded = Settings::load(&settings_path); assert!(!reloaded.css_entries.iter().any(|e| css_entry_id(e) == id)); } #[test] fn remove_css_rejects_linked_entry() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let css_dir = dir.path().to_path_buf(); let mut settings = Settings::load(&settings_path); settings .link_css(&settings_path, "https://example.com/style.css".to_string()) .unwrap(); let linked_id = css_entry_id(&settings.css_entries[0]); let result = settings.remove_css(&settings_path, &css_dir, &linked_id); assert!(matches!(result, Err(CssError::NotImported))); // Entry must still be there — rejection shouldn't silently drop it. assert_eq!(settings.css_entries.len(), 1); } #[test] fn remove_css_rejects_unknown_id() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let css_dir = dir.path().to_path_buf(); let mut settings = Settings::load(&settings_path); let result = settings.remove_css(&settings_path, &css_dir, "does-not-exist"); assert!(matches!(result, Err(CssError::NotFound))); } #[test] fn css_content_rejects_linked_entry() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let css_dir = dir.path().to_path_buf(); let mut settings = Settings::load(&settings_path); settings .link_css(&settings_path, "https://example.com/style.css".to_string()) .unwrap(); let linked_id = css_entry_id(&settings.css_entries[0]); let result = settings.css_content(&css_dir, &linked_id); assert!(matches!(result, Err(CssError::NotImported))); } /// Acceptance criterion (ticket 049): a pre-existing legacy `profiles_dir/profiles.json` is /// merged into `settings.json` on the next load, and the old file is renamed (not re-imported on /// a subsequent load). #[test] fn load_migrates_legacy_profiles_file_into_inline_settings() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let profiles_dir = dir.path().join("profiles"); fs::create_dir_all(&profiles_dir).unwrap(); let legacy_path = profiles_dir.join("profiles.json"); fs::write( &legacy_path, r#"[{"name":"web","params":[],"metrics":[],"dashboards":[]}]"#, ) .unwrap(); // No settings.json yet: simulates upgrading a pre-049 install that never wrote one either, // or one that did but whose nested pointer already carried an active_profile name. fs::write( &settings_path, r#"{"color_scheme":"system","css_entries":[],"custom_theme_id":null,"profiles":{"names":["web"],"active_profile":"web","profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#, ) .unwrap(); let settings = Settings::load(&settings_path); assert_eq!(settings.profiles.len(), 1); assert_eq!(settings.profiles[0].name, "web"); assert_eq!(settings.active_profile, Some("web".to_string())); // The legacy file is gone (renamed), so it isn't re-imported. assert!(!legacy_path.exists()); let migrated_path = legacy_path.with_extension("json.migrated"); assert!(migrated_path.exists()); // Reload from disk: the migrated settings.json now carries profiles inline directly. let reloaded = Settings::load(&settings_path); assert_eq!(reloaded.profiles.len(), 1); assert_eq!(reloaded.profiles[0].name, "web"); } /// A fresh install (no legacy file at all) never creates `profiles.json` — it's not even looked /// for beyond the one migration check, and `profiles` simply stays empty. #[test] fn load_without_legacy_file_leaves_profiles_empty() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let settings = Settings::load(&settings_path); assert!(settings.profiles.is_empty()); assert!(!dir.path().join("profiles").exists()); } }