Merge ticket 049 (inline profiles into settings.json) with auto-derived-param promotion
worktree-agent-a19b9ef959dcda6b2 (ticket 049) was branched from before "Auto
derived format" (1a481d3) landed on main, so its synchronous
persist_metric_declaration(_update) and ProfileStore::record_metric_declaration(_update)
didn't know about promoting auto-derived engine params into the persisted
profile. This merge reconciles the two:
- src/api.rs: kept the async persist_metric_declaration/_update methods and
promoted_param_records helper from "Auto derived format", but switched their
persistence from ProfileStore::load/record_metric_declaration to the new
Settings-based settings.record_metric_declaration(_update), matching ticket
049's single-settings.json model. Dropped the now-unused
active_profile_location helper in favor of locking self.settings directly.
- src/profile.rs: removed the standalone ProfileStore struct/impl entirely
(049 folds profiles into Settings), keeping load_legacy_profile_store for
migrating a pre-049 profiles.json. Extended record_metric_declaration and
record_metric_declaration_update (now methods on Settings) to keep taking
promoted_params: &[ParamDeclarationRecord], persisting promoted auto-derived
params alongside the metric via Settings::save. Moved the ticket 037/038
legacy-shape migration (migrate_legacy_metric_params /
migrate_duration_value_params) out of the removed ProfileStore::load into a
new pub(crate) migrate_legacy_profile_shapes(profiles), invoked from
Settings::load (via a new Settings::migrate_legacy_profile_shapes) so
profiles inlined in settings.json still get migrated forward on every load.
Rewrote the ProfileStore-based tests (legacy-shape migration tests, the
no-op-migration test, and the auto-derived-param promotion round-trip test)
to exercise Settings directly, including a write_legacy_settings_json helper
that replaces the old write_legacy_profiles_json.
- src/api.rs tests: rewrote declaring_metric_against_auto_derived_param_promotes_it_into_the_profile
to create/activate the profile via settings_handle.lock() and reload via
Settings::load instead of ProfileStore.
Also fixed one clippy::collapsible_if warning introduced by the new
Settings::migrate_legacy_profile_shapes.
cargo build, cargo test (222 passed, 0 failed, 0 ignored), and
cargo clippy --all-targets all pass; no new clippy warnings beyond
pre-existing ones in files untouched by this merge.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
+219
-38
@@ -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,21 @@ 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.migrate_legacy_profile_shapes(path);
|
||||
settings
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"warning: failed to parse settings file at {}: {err}; using defaults",
|
||||
@@ -125,7 +186,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 +215,60 @@ 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -315,6 +431,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 +459,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 +588,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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user