Add process-wide Settings model (ticket 008)
Introduces src/settings.rs with a single, process-wide Settings struct
(colour scheme, CSS entries shaped for imported-vs-linked, and a
profiles placeholder for ticket 009), JSON-serialized via serde_json to
keep deps minimal. Default settings path is computed with the
directories crate (ProjectDirs::from("", "", "ottyl")) and overridable
via a new --settings-path CLI arg. Settings::load creates and persists
a default file on first run; Settings::save persists changes.
Wires a single Arc<Mutex<Settings>> into main() and threads it through
api::router as an additive ApiState field for tickets 010/011 to build
on.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1fb564d06b
commit
22647620fa
+199
@@ -0,0 +1,199 @@
|
||||
//! 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<String>,
|
||||
/// Name of the currently active profile, if any.
|
||||
pub active_profile: Option<String>,
|
||||
/// 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<CssEntry>,
|
||||
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: `<config_dir>/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<PathBuf> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user