Files
ottyl/src/settings.rs
T
Hugh KaznowskiandClaude Sonnet 5 ed0be9726f Remove duplicate "grey" theme, keep "dark" (ticket 004)
"grey" and "dark" rendered identically apart from accent colour, so
drop "grey" as a selectable colour scheme everywhere in the frontend
(index.css, api.ts's ColorScheme type, the NavMenu picker, the
downloadable BUILTIN_THEMES table) and keep "dark" as the single
equivalent theme.

Existing settings.json files saved with color_scheme: "grey" keep
loading correctly: the backend ColorScheme enum aliases the legacy
"grey" serde value onto Dark, so old configs transparently migrate to
"dark" without any manual intervention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 18:42:40 +01:00

480 lines
17 KiB
Rust

//! Process-wide settings model: colour scheme, CSS entries, and profile pointers, 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;
/// Shared, process-wide settings handle. Cheap to clone.
pub type SettingsHandle = Arc<Mutex<Settings>>;
/// 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). `is_theme` marks it as a selectable colour-scheme theme rather than always-injected CSS.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum CssEntry {
Imported {
id: String,
filename: String,
#[serde(default)]
is_theme: bool,
},
Linked {
path_or_url: String,
#[serde(default)]
is_theme: bool,
},
}
/// Tracks known profile names and which one is active; the profile files themselves live elsewhere.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Profiles {
/// Names of known profiles.
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. Only `PartialEq` (not `Eq`): `alert_rules` carries `f64` fields, which aren't `Eq`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Settings {
pub color_scheme: ColorScheme,
pub css_entries: Vec<CssEntry>,
/// 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<String>,
pub profiles: Profiles,
/// Maximum number of ingested logs kept in memory; `None` means unlimited.
#[serde(default)]
pub max_logs: Option<usize>,
/// Alert rule definitions.
#[serde(default)]
pub alert_rules: Vec<AlertRule>,
/// Ntfy topic every alert rule delivers to; `None` until configured in Settings.
#[serde(default)]
pub ntfy_topic: Option<String>,
/// Ntfy server override; `None` means `https://ntfy.sh`.
#[serde(default)]
pub ntfy_server: Option<String>,
}
impl Default for Settings {
fn default() -> Self {
Settings {
color_scheme: ColorScheme::default(),
css_entries: Vec::new(),
custom_theme_id: None,
profiles: Profiles {
names: Vec::new(),
active_profile: None,
profiles_dir: "profiles".to_string(),
},
max_logs: None,
alert_rules: Vec::new(),
ntfy_topic: None,
ntfy_server: None,
}
}
}
/// Computes the default settings file path (`<config_dir>/settings.json`) from OS-conventional config directories; `None` if none could be determined.
pub fn default_settings_path() -> Option<PathBuf> {
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.
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)
}
}
/// 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<io::Error> 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_dir>/<id>.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,
is_theme: bool,
) -> Result<String, CssError> {
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,
is_theme,
});
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,
is_theme: bool,
) -> Result<(), CssError> {
self.css_entries.push(CssEntry::Linked {
path_or_url,
is_theme,
});
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<String, CssError> {
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);
}
#[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(),
is_theme: false,
});
settings.css_entries.push(CssEntry::Linked {
path_or_url: "https://example.com/style.css".to_string(),
is_theme: false,
});
settings.profiles.names.push("default".to_string());
settings.profiles.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; }",
false,
)
.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{}", false)
.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(), false)
.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(), false)
.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)));
}
}