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:
Hugh Kaznowski
2026-09-06 02:03:22 +01:00
co-authored by Claude Sonnet 5
6 changed files with 624 additions and 494 deletions
+61 -100
View File
@@ -13,7 +13,7 @@ use crate::engine::{
EngineResponse,
};
use crate::alerts::{deliver_ntfy, AlertError};
use crate::profile::{self, ParamDeclarationRecord, Profile, ProfileError, ProfileStore};
use crate::profile::{self, Dashboard, ParamDeclarationRecord, Profile, ProfileError};
use crate::settings::{CssError, SettingsHandle};
use axum::extract::{Path as AxumPath, Query, State};
use axum::http::{header, StatusCode};
@@ -43,11 +43,11 @@ impl ApiState {
/// Best-effort: a no-op if no profile is active, and I/O failures are only logged, since the engine declaration is already the source of truth.
fn persist_param_declaration(&self, request: &DeclareParamRequest) {
let Some((store_path, profile_name)) = self.active_profile_location() else {
let mut settings = self.settings.lock().unwrap();
let Some(profile_name) = settings.active_profile.clone() else {
return;
};
let mut store = ProfileStore::load(&store_path);
if let Err(err) = store.record_param_declaration(&store_path, &profile_name, request) {
if let Err(err) = settings.record_param_declaration(&self.settings_path, &profile_name, request) {
eprintln!(
"warning: failed to persist param declaration '{}' into profile '{profile_name}': {err}",
request.name
@@ -61,15 +61,25 @@ impl ApiState {
/// auto-detected logfmt key survives a restart instead of being rejected as "undeclared" once
/// declarations replay before any log line re-derives it.
async fn persist_metric_declaration(&self, request: &DeclareMetricRequest) {
let Some((store_path, profile_name)) = self.active_profile_location() else {
return;
let profile_name = {
let settings = self.settings.lock().unwrap();
let Some(profile_name) = settings.active_profile.clone() else {
return;
};
profile_name
};
let existing_params = {
let settings = self.settings.lock().unwrap();
settings.profile(&profile_name).map(|p| p.params.clone()).unwrap_or_default()
};
let mut store = ProfileStore::load(&store_path);
let existing_params = store.get(&profile_name).map(|p| p.params.clone()).unwrap_or_default();
let promoted = promoted_param_records(&self.engine, &existing_params, request).await;
if let Err(err) =
store.record_metric_declaration(&store_path, &profile_name, request, &promoted)
{
let mut settings = self.settings.lock().unwrap();
if let Err(err) = settings.record_metric_declaration(
&self.settings_path,
&profile_name,
request,
&promoted,
) {
eprintln!(
"warning: failed to persist metric declaration '{}' into profile '{profile_name}': {err}",
request.name
@@ -79,14 +89,21 @@ impl ApiState {
/// Same as `persist_metric_declaration`, for an in-place edit (`PUT /api/metrics/{name}`) — also drops the persisted record under `old_name` when the edit renames the metric, so a rename doesn't leave a stale duplicate declaration in the profile file.
async fn persist_metric_declaration_update(&self, old_name: &str, request: &DeclareMetricRequest) {
let Some((store_path, profile_name)) = self.active_profile_location() else {
return;
let profile_name = {
let settings = self.settings.lock().unwrap();
let Some(profile_name) = settings.active_profile.clone() else {
return;
};
profile_name
};
let existing_params = {
let settings = self.settings.lock().unwrap();
settings.profile(&profile_name).map(|p| p.params.clone()).unwrap_or_default()
};
let mut store = ProfileStore::load(&store_path);
let existing_params = store.get(&profile_name).map(|p| p.params.clone()).unwrap_or_default();
let promoted = promoted_param_records(&self.engine, &existing_params, request).await;
if let Err(err) = store.record_metric_declaration_update(
&store_path,
let mut settings = self.settings.lock().unwrap();
if let Err(err) = settings.record_metric_declaration_update(
&self.settings_path,
&profile_name,
old_name,
request,
@@ -98,13 +115,6 @@ impl ApiState {
);
}
}
fn active_profile_location(&self) -> Option<(PathBuf, String)> {
let settings = self.settings.lock().unwrap();
let profile_name = settings.profiles.active_profile.clone()?;
let profiles_dir = profile::resolve_profiles_dir(&settings, &self.settings_path);
Some((profile::profiles_store_path(&profiles_dir), profile_name))
}
}
pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: PathBuf) -> Router {
@@ -621,9 +631,7 @@ fn profile_error_response(err: ProfileError) -> axum::response::Response {
async fn list_profiles(State(state): State<ApiState>) -> impl IntoResponse {
let settings = state.settings.lock().unwrap();
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
let summaries: Vec<ProfileSummaryDto> = store.list().iter().map(ProfileSummaryDto::from).collect();
let summaries: Vec<ProfileSummaryDto> = settings.profiles.iter().map(ProfileSummaryDto::from).collect();
Json(summaries).into_response()
}
@@ -632,12 +640,9 @@ async fn create_profile(
Json(body): Json<CreateProfileRequest>,
) -> impl IntoResponse {
let mut settings = state.settings.lock().unwrap();
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
let store_path = profile::profiles_store_path(&profiles_dir);
let mut store = ProfileStore::load(&store_path);
match store.create(&store_path, &mut settings, &state.settings_path, body.name.clone()) {
match settings.create_profile(&state.settings_path, body.name.clone()) {
Ok(()) => {
let profile = store.get(&body.name).expect("profile was just created by store.create");
let profile = settings.profile(&body.name).expect("profile was just created by create_profile");
(StatusCode::CREATED, Json(ProfileSummaryDto::from(profile))).into_response()
}
Err(err) => profile_error_response(err),
@@ -650,10 +655,7 @@ async fn delete_profile(
AxumPath(name): AxumPath<String>,
) -> impl IntoResponse {
let mut settings = state.settings.lock().unwrap();
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
let store_path = profile::profiles_store_path(&profiles_dir);
let mut store = ProfileStore::load(&store_path);
match store.delete(&store_path, &mut settings, &state.settings_path, &name) {
match settings.delete_profile(&state.settings_path, &name) {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => profile_error_response(err),
}
@@ -701,11 +703,8 @@ async fn activate_profile(
) -> impl IntoResponse {
let activated_profile = {
let mut settings = state.settings.lock().unwrap();
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
let store_path = profile::profiles_store_path(&profiles_dir);
let store = ProfileStore::load(&store_path);
match store.set_active(&mut settings, &state.settings_path, Some(name.clone())) {
Ok(()) => Ok(store.get(&name).cloned()),
match settings.set_active_profile(&state.settings_path, Some(name.clone())) {
Ok(()) => Ok(settings.profile(&name).cloned()),
Err(err) => Err(err),
}
};
@@ -727,9 +726,7 @@ async fn list_dashboards(
AxumPath(name): AxumPath<String>,
) -> impl IntoResponse {
let settings = state.settings.lock().unwrap();
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
match store.list_dashboards(&name) {
match settings.list_dashboards(&name) {
Ok(dashboards) => {
let dtos: Vec<DashboardDto> = dashboards.iter().map(DashboardDto::from).collect();
Json(dtos).into_response()
@@ -743,11 +740,8 @@ async fn create_dashboard(
AxumPath(name): AxumPath<String>,
Json(body): Json<CreateDashboardRequest>,
) -> impl IntoResponse {
let settings = state.settings.lock().unwrap();
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
let store_path = profile::profiles_store_path(&profiles_dir);
let mut store = ProfileStore::load(&store_path);
match store.create_dashboard(&store_path, &name, body.name) {
let mut settings = state.settings.lock().unwrap();
match settings.create_dashboard(&state.settings_path, &name, body.name) {
Ok(dashboard) => (StatusCode::CREATED, Json(DashboardDto::from(&dashboard))).into_response(),
Err(err) => profile_error_response(err),
}
@@ -758,9 +752,7 @@ async fn get_dashboard(
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
) -> impl IntoResponse {
let settings = state.settings.lock().unwrap();
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
match store.list_dashboards(&name) {
match settings.list_dashboards(&name) {
Ok(dashboards) => match dashboards.iter().find(|d| d.id == dashboard_id) {
Some(dashboard) => Json(DashboardDto::from(dashboard)).into_response(),
None => profile_error_response(ProfileError::NotFound),
@@ -774,16 +766,13 @@ async fn save_dashboard(
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
Json(body): Json<SaveDashboardRequest>,
) -> impl IntoResponse {
let settings = state.settings.lock().unwrap();
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
let store_path = profile::profiles_store_path(&profiles_dir);
let mut store = ProfileStore::load(&store_path);
let dashboard = crate::profile::Dashboard {
let mut settings = state.settings.lock().unwrap();
let dashboard = Dashboard {
id: dashboard_id.clone(),
name: body.name,
panels: body.panels.iter().map(|p| p.into()).collect(),
};
match store.save_dashboard(&store_path, &name, &dashboard_id, dashboard) {
match settings.save_dashboard(&state.settings_path, &name, &dashboard_id, dashboard) {
Ok(dashboard) => Json(DashboardDto::from(&dashboard)).into_response(),
Err(err) => profile_error_response(err),
}
@@ -793,11 +782,8 @@ async fn delete_dashboard(
State(state): State<ApiState>,
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
) -> impl IntoResponse {
let settings = state.settings.lock().unwrap();
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
let store_path = profile::profiles_store_path(&profiles_dir);
let mut store = ProfileStore::load(&store_path);
match store.delete_dashboard(&store_path, &name, &dashboard_id) {
let mut settings = state.settings.lock().unwrap();
match settings.delete_dashboard(&state.settings_path, &name, &dashboard_id) {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => profile_error_response(err),
}
@@ -1231,20 +1217,10 @@ mod tests {
// Use the same shared settings handle the router was built with — a separately-loaded Settings wouldn't be visible to the running app.
let settings_path = dir.path().join("settings.json");
let profiles_dir = {
let settings = settings_handle.lock().unwrap();
profile::resolve_profiles_dir(&settings, &settings_path)
};
let store_path = profile::profiles_store_path(&profiles_dir);
let mut store = ProfileStore::load(&store_path);
{
let mut settings = settings_handle.lock().unwrap();
store
.create(&store_path, &mut settings, &settings_path, "web".to_string())
.unwrap();
store
.set_active(&mut settings, &settings_path, Some("web".to_string()))
.unwrap();
settings.create_profile(&settings_path, "web".to_string()).unwrap();
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
}
let (status, _body) = send_json(
@@ -1261,8 +1237,8 @@ mod tests {
.await;
assert_eq!(status, StatusCode::CREATED);
let reloaded_store = ProfileStore::load(&store_path);
let profile = reloaded_store.get("web").unwrap();
let reloaded_settings = Settings::load(&settings_path);
let profile = reloaded_settings.profile("web").unwrap();
assert_eq!(profile.params.len(), 1);
assert_eq!(profile.params[0].name, "level");
}
@@ -1277,20 +1253,10 @@ mod tests {
let (app, engine, settings_handle, dir) = test_app();
let settings_path = dir.path().join("settings.json");
let profiles_dir = {
let settings = settings_handle.lock().unwrap();
profile::resolve_profiles_dir(&settings, &settings_path)
};
let store_path = profile::profiles_store_path(&profiles_dir);
let mut store = ProfileStore::load(&store_path);
{
let mut settings = settings_handle.lock().unwrap();
store
.create(&store_path, &mut settings, &settings_path, "web".to_string())
.unwrap();
store
.set_active(&mut settings, &settings_path, Some("web".to_string()))
.unwrap();
settings.create_profile(&settings_path, "web".to_string()).unwrap();
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
}
// Ingest a line carrying a logfmt key before any param is manually declared, so "dx" only
@@ -1310,8 +1276,8 @@ mod tests {
.await;
assert_eq!(status, StatusCode::CREATED);
let reloaded_store = ProfileStore::load(&store_path);
let profile = reloaded_store.get("web").unwrap();
let reloaded_settings = Settings::load(&settings_path);
let profile = reloaded_settings.profile("web").unwrap();
assert_eq!(profile.metrics.len(), 1);
assert_eq!(profile.metrics[0].name, "direction_x");
// The auto-derived "dx" param got promoted into the profile alongside the metric.
@@ -1413,7 +1379,7 @@ mod tests {
#[tokio::test]
async fn saved_dashboard_persists_across_simulated_restart() {
let (app, _engine, settings_handle, dir) = test_app();
let (app, _engine, _settings_handle, dir) = test_app();
let (status, _body) = send_json(&app, "POST", "/api/settings/profiles", json!({ "name": "web" })).await;
assert_eq!(status, StatusCode::CREATED);
@@ -1440,15 +1406,10 @@ mod tests {
)
.await;
// Simulate a restart by reloading the profile store fresh from disk, independent of the running app/engine.
// Simulate a restart by reloading settings fresh from disk, independent of the running app/engine.
let settings_path = dir.path().join("settings.json");
let profiles_dir = {
let settings = settings_handle.lock().unwrap();
profile::resolve_profiles_dir(&settings, &settings_path)
};
let store_path = profile::profiles_store_path(&profiles_dir);
let reloaded_store = ProfileStore::load(&store_path);
let dashboards = reloaded_store.list_dashboards("web").unwrap();
let reloaded_settings = Settings::load(&settings_path);
let dashboards = reloaded_settings.list_dashboards("web").unwrap();
assert_eq!(dashboards.len(), 1);
assert_eq!(dashboards[0].name, "Overview");
assert_eq!(dashboards[0].panels.len(), 1);
+1 -1
View File
@@ -198,7 +198,7 @@ impl From<&Settings> for SettingsDto {
fn from(settings: &Settings) -> Self {
SettingsDto {
color_scheme: settings.color_scheme,
active_profile: settings.profiles.active_profile.clone(),
active_profile: settings.active_profile.clone(),
max_logs: settings.max_logs,
custom_theme_id: settings.custom_theme_id.clone(),
ntfy_topic: settings.ntfy_topic.clone(),
+18 -19
View File
@@ -22,7 +22,6 @@ use clap::Parser;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use profile::ProfileStore;
use settings::Settings;
/// The resolved default settings path, rendered into `--help` so `--settings-path`'s help text
@@ -76,28 +75,28 @@ async fn main() {
// Reload the active profile's declarations before ingest starts: there's no way to
// reprocess early lines later, so declaring after ingest starts could miss lines that raced ahead.
{
let (active_profile_name, profiles_dir) = {
let (active_profile_name, active_profile) = {
let mut settings_guard = settings.lock().unwrap();
let profiles_dir = profile::resolve_profiles_dir(&settings_guard, &settings_path);
let mut store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
store
.ensure_default(&profile::profiles_store_path(&profiles_dir), &mut settings_guard, &settings_path)
settings_guard
.ensure_default_profile(&settings_path)
.expect("failed to ensure the Default profile exists");
(settings_guard.profiles.active_profile.clone(), profiles_dir)
let active_profile_name = settings_guard.active_profile.clone();
let active_profile = active_profile_name
.as_deref()
.and_then(|name| settings_guard.profile(name).cloned());
(active_profile_name, active_profile)
};
if let Some(profile_name) = active_profile_name {
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
match store.get(&profile_name) {
Some(active_profile) => {
profile::load_into_engine(&engine_tx, active_profile).await;
}
None => {
eprintln!(
"warning: active profile '{profile_name}' not found under {}; skipping",
profiles_dir.display()
);
}
match (active_profile_name, active_profile) {
(Some(_), Some(active_profile)) => {
profile::load_into_engine(&engine_tx, &active_profile).await;
}
(Some(profile_name), None) => {
eprintln!(
"warning: active profile '{profile_name}' not found in {}; skipping",
settings_path.display()
);
}
(None, _) => {}
}
}
+241 -336
View File
File diff suppressed because it is too large Load Diff
+219 -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,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());
}
}