//! Process-wide settings model: colour scheme, CSS entries, and profile //! pointers. There is exactly one `Settings` instance at runtime, loaded once //! on startup and persisted to disk whenever it changes (see `main.rs`). //! //! Persistence format is JSON (via `serde_json`, already a dependency) rather //! than TOML, to keep the dependency list minimal. use std::fs; use std::io; use std::path::{Path, PathBuf}; use directories::ProjectDirs; use serde::{Deserialize, Serialize}; /// User's preferred colour scheme for the webapp. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum ColorScheme { Light, Dark, #[default] System, } /// A single CSS entry configured for the webapp: either content that ottyl /// stores itself ("imported") or a reference to an external path/URL that /// the webapp links to directly ("linked", and therefore not deletable via /// the settings API since ottyl doesn't own it). /// /// Ticket 010 builds the CSS import/link HTTP API around this shape. #[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 }, } /// Placeholder shape for profile management (ticket 009 builds CRUD on top /// of this). Profiles are expected to live as individual files under a /// dedicated directory; this struct only tracks known profile names and /// which one is currently active. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct Profiles { /// Names of known profiles. Ticket 009 owns creating/loading the actual /// profile files. pub names: Vec, /// Name of the currently active profile, if any. pub active_profile: Option, /// Directory (relative to the settings file's directory, or absolute) /// where individual profile files are expected to live. pub profiles_dir: String, } /// The single, process-wide settings instance. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Settings { pub color_scheme: ColorScheme, pub css_entries: Vec, pub profiles: Profiles, } impl Default for Settings { fn default() -> Self { Settings { color_scheme: ColorScheme::default(), css_entries: Vec::new(), profiles: Profiles { names: Vec::new(), active_profile: None, profiles_dir: "profiles".to_string(), }, } } } /// Computes the default settings file path using OS-conventional config /// directories: `/settings.json`, where `config_dir` comes from /// `directories::ProjectDirs::from("", "", "ottyl")`. /// /// Returns `None` if no valid config directory could be determined for the /// current OS/user (this mirrors `ProjectDirs::from`'s own fallibility). pub fn default_settings_path() -> Option { ProjectDirs::from("", "", "ottyl").map(|dirs| dirs.config_dir().join("settings.json")) } impl Settings { /// Loads settings from `path`. If the file doesn't exist yet, a default /// `Settings` is created, persisted to `path`, and returned. /// /// Any I/O or parse error while reading an existing file is treated as /// non-fatal: it is logged to stderr and a default `Settings` is /// returned (without overwriting the unreadable file). pub fn load(path: &Path) -> Settings { match fs::read_to_string(path) { Ok(contents) => match serde_json::from_str(&contents) { Ok(settings) => 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 settings = Settings::default(); 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) } } #[cfg(test)] mod tests { use super::*; #[test] fn default_settings_path_ends_with_settings_json() { // We can't assert the exact OS path here without touching real // config dirs, but we can verify the path-computation logic shapes // it as expected. 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 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.names.push("default".to_string()); settings.profiles.active_profile = Some("default".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()); } }