Extends ticket 048's light_green pattern (light-inverted surface/border/text scale + light's neutral danger colours, same base and accent hues as the dark-surfaced counterpart) to the remaining five colour schemes across all six files that must stay in sync: index.css, themes.ts, api.ts, settings.rs, useSettingsPanel.ts, and NavMenu.tsx. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
678 lines
28 KiB
Rust
678 lines
28 KiB
Rust
//! 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<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,
|
|
/// Brighter, light-surfaced counterpart to `Green` (ticket 048).
|
|
#[serde(rename = "light_green")]
|
|
LightGreen,
|
|
/// Brighter, light-surfaced counterpart to `Red` (ticket 056).
|
|
#[serde(rename = "light_red")]
|
|
LightRed,
|
|
/// Brighter, light-surfaced counterpart to `Blue` (ticket 056).
|
|
#[serde(rename = "light_blue")]
|
|
LightBlue,
|
|
/// Brighter, light-surfaced counterpart to `Yellow` (ticket 056).
|
|
#[serde(rename = "light_yellow")]
|
|
LightYellow,
|
|
/// Brighter, light-surfaced counterpart to `Pink` (ticket 056).
|
|
#[serde(rename = "light_pink")]
|
|
LightPink,
|
|
/// Brighter, light-surfaced counterpart to `Orange` (ticket 056).
|
|
#[serde(rename = "light_orange")]
|
|
LightOrange,
|
|
#[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<String>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// On-disk shape of the `profiles` field: either the new inline `Vec<Profile>` (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<Profile>),
|
|
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<CssEntry>,
|
|
#[serde(default)]
|
|
custom_theme_id: Option<String>,
|
|
#[serde(default)]
|
|
profiles: ProfilesOnDisk,
|
|
#[serde(default)]
|
|
active_profile: Option<String>,
|
|
#[serde(default)]
|
|
max_logs: Option<usize>,
|
|
#[serde(default)]
|
|
alert_rules: Vec<AlertRule>,
|
|
#[serde(default)]
|
|
ntfy_topic: Option<String>,
|
|
#[serde(default)]
|
|
ntfy_server: Option<String>,
|
|
}
|
|
|
|
impl From<SettingsOnDisk> 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<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>,
|
|
/// Every known profile, inline (ticket 049).
|
|
pub profiles: Vec<Profile>,
|
|
/// Name of the currently active profile, if any.
|
|
pub active_profile: Option<String>,
|
|
/// 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>,
|
|
}
|
|
|
|
/// 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. 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::<Settings>(&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
|
|
/// `<settings_path's parent>/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<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,
|
|
) -> 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,
|
|
});
|
|
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<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 light_variant_color_schemes_round_trip_through_serde() {
|
|
for (scheme, rename) in [
|
|
(ColorScheme::LightGreen, "light_green"),
|
|
(ColorScheme::LightRed, "light_red"),
|
|
(ColorScheme::LightBlue, "light_blue"),
|
|
(ColorScheme::LightYellow, "light_yellow"),
|
|
(ColorScheme::LightPink, "light_pink"),
|
|
(ColorScheme::LightOrange, "light_orange"),
|
|
] {
|
|
let json = serde_json::to_string(&scheme).unwrap();
|
|
assert_eq!(json, format!("\"{rename}\""));
|
|
let deserialized: ColorScheme = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(deserialized, scheme);
|
|
}
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|