Inline profile storage into settings.json (ticket 049)

Fold ProfileStore's separate profiles.json into Settings itself, so
settings.json is the single source of truth instead of two files kept
in sync by hand. Settings now carries `profiles: Vec<Profile>` and
`active_profile: Option<String>` directly; every former ProfileStore
mutation (create, delete, set_active, record_param_declaration,
record_metric_declaration, dashboard CRUD, ensure_default) is now a
method on Settings that persists via Settings::save.

A custom Deserialize (via SettingsOnDisk) keeps an old settings.json
(with the removed Profiles pointer struct) loading cleanly, and
Settings::load runs a one-time migration: if profiles is empty and a
legacy <settings_dir>/profiles/profiles.json exists, its contents are
merged in, settings.json is resaved, and the old file is renamed so it
isn't re-imported.

Updated all call sites in api.rs and main.rs to the single-path API,
and ported profile.rs's ProfileStore tests to exercise Settings
directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-06 01:54:04 +01:00
co-authored by Claude Sonnet 5
parent 85fb145be7
commit e8baf2b004
5 changed files with 421 additions and 399 deletions
+201 -38
View File
@@ -1,4 +1,5 @@
//! Process-wide settings model: colour scheme, CSS entries, and profile pointers, persisted as JSON.
//! 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;
@@ -13,6 +14,7 @@ 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>>;
@@ -51,27 +53,101 @@ pub enum CssEntry {
Linked { path_or_url: String },
}
/// 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,
/// 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`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
///
/// `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>,
pub profiles: Profiles,
/// 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>,
@@ -86,36 +162,20 @@ pub struct Settings {
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.
/// 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(settings) => settings,
Ok(contents) => match serde_json::from_str::<Settings>(&contents) {
Ok(mut settings) => {
settings.migrate_legacy_profiles_file(path);
settings
}
Err(err) => {
eprintln!(
"warning: failed to parse settings file at {}: {err}; using defaults",
@@ -125,7 +185,8 @@ impl Settings {
}
},
Err(err) if err.kind() == io::ErrorKind::NotFound => {
let settings = Settings::default();
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}",
@@ -153,6 +214,43 @@ impl Settings {
.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()
);
}
}
}
/// Error returned by CSS-entry mutations and lookups.
@@ -315,6 +413,20 @@ mod tests {
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();
@@ -329,8 +441,8 @@ mod tests {
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.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(),
@@ -458,4 +570,55 @@ mod tests {
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());
}
}