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
+163 -260
View File
@@ -1,5 +1,8 @@
//! Named, persistable bundles of parsing/indexing rules and dashboards for a CLI app, persisted as a single `profiles.json`.
//! `Settings.profiles` only tracks lightweight name/active pointers; this module owns the actual content.
//! Named, persistable bundles of parsing/indexing rules and dashboards for a CLI app.
//! Ticket 049 folded profile storage inline into `Settings` (`settings.json`) instead of a separate
//! `profiles.json`; this module still owns the `Profile`/`Dashboard`/declaration data model, plus the
//! CRUD methods on `Settings` (`impl Settings` below `ProfileError`) that used to live on the
//! now-removed `ProfileStore`.
#![allow(dead_code)]
use std::collections::hash_map::DefaultHasher;
@@ -8,7 +11,7 @@ use std::fmt;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
@@ -18,7 +21,7 @@ use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, Eng
use crate::settings::Settings;
use tokio::sync::oneshot;
/// Name of the profile that always exists and can't be deleted (see `ProfileStore::ensure_default`).
/// Name of the profile that always exists and can't be deleted (see `Settings::ensure_default_profile`).
pub const DEFAULT_PROFILE_NAME: &str = "Default";
/// Mirrors `data_value::MetricPrimitiveKind` with `Serialize`/`Deserialize` so profiles can round-trip through JSON, since the engine-internal type only derives `Serialize`.
@@ -325,7 +328,7 @@ pub async fn load_into_engine(engine: &EngineHandle, profile: &Profile) {
}
}
/// Error returned by `ProfileStore` mutations and lookups.
/// Error returned by `Settings`'s profile-related mutations and lookups.
#[derive(Debug)]
pub enum ProfileError {
/// No profile with the given name exists.
@@ -357,168 +360,106 @@ impl From<io::Error> for ProfileError {
}
}
/// Resolves `settings.profiles.profiles_dir` relative to `settings_path`'s parent directory (or as absolute if it already is one).
pub fn resolve_profiles_dir(settings: &Settings, settings_path: &Path) -> PathBuf {
let profiles_dir = Path::new(&settings.profiles.profiles_dir);
if profiles_dir.is_absolute() {
profiles_dir.to_path_buf()
} else {
settings_path
.parent()
.map(|parent| parent.join(profiles_dir))
.unwrap_or_else(|| profiles_dir.to_path_buf())
}
}
/// Path to the single file holding all profiles, within `profiles_dir`.
pub fn profiles_store_path(profiles_dir: &Path) -> PathBuf {
profiles_dir.join("profiles.json")
}
/// On-disk collection of all known profiles, persisted as a single JSON file.
#[derive(Debug, Clone, Default)]
pub struct ProfileStore {
profiles: Vec<Profile>,
}
impl ProfileStore {
/// Loads the store from `store_path`; a missing or unreadable/unparsable file is treated as an empty store rather than failing startup.
pub fn load(store_path: &Path) -> ProfileStore {
match fs::read_to_string(store_path) {
Ok(contents) => match serde_json::from_str::<Vec<Profile>>(&contents) {
Ok(profiles) => ProfileStore { profiles },
Err(err) => {
eprintln!(
"warning: failed to parse profile store at {}: {err}; using empty store",
store_path.display()
);
ProfileStore::default()
}
},
Err(err) if err.kind() == io::ErrorKind::NotFound => ProfileStore::default(),
Err(err) => {
eprintln!(
"warning: failed to read profile store at {}: {err}; using empty store",
store_path.display()
);
ProfileStore::default()
}
/// Reads a legacy pre-049 `profiles.json` file (back when profiles lived in a separate file rather
/// than inline in `settings.json`) for `Settings::migrate_legacy_profiles_file`. Returns `None` if
/// the file doesn't exist or can't be read/parsed — treated as "nothing to migrate" rather than
/// failing settings load.
pub(crate) fn load_legacy_profile_store(path: &Path) -> Option<Vec<Profile>> {
let contents = fs::read_to_string(path).ok()?;
match serde_json::from_str::<Vec<Profile>>(&contents) {
Ok(profiles) => Some(profiles),
Err(err) => {
eprintln!(
"warning: failed to parse legacy profile store at {}: {err}; skipping migration",
path.display()
);
None
}
}
}
/// Persists the store to `store_path` as pretty-printed JSON, creating any missing parent directories first.
pub fn save(&self, store_path: &Path) -> io::Result<()> {
if let Some(parent) = store_path.parent() {
fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(&self.profiles)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
fs::write(store_path, contents)
}
pub fn list(&self) -> &[Profile] {
&self.profiles
}
pub fn get(&self, name: &str) -> Option<&Profile> {
/// Profile CRUD, previously on the standalone `ProfileStore` (removed by ticket 049): every mutation
/// here reads/writes `self.profiles` directly and persists via `Settings::save`, so `settings.json` is
/// the single source of truth instead of a separate file kept in sync alongside it.
impl Settings {
pub fn profile(&self, name: &str) -> Option<&Profile> {
self.profiles.iter().find(|p| p.name == name)
}
/// Creates a new profile named `name` (persisting store and settings), copying the active profile's params/metrics/dashboards as a starting point rather than starting blank. Fails with `DuplicateName` if the name exists.
pub fn create(
&mut self,
store_path: &Path,
settings: &mut Settings,
settings_path: &Path,
name: String,
) -> Result<(), ProfileError> {
if self.get(&name).is_some() {
/// Creates a new profile named `name`, persisting settings, copying the active profile's
/// params/metrics/dashboards as a starting point rather than starting blank. Fails with
/// `DuplicateName` if the name exists.
pub fn create_profile(&mut self, settings_path: &Path, name: String) -> Result<(), ProfileError> {
if self.profile(&name).is_some() {
return Err(ProfileError::DuplicateName);
}
let mut profile = Profile::new(name.clone());
if let Some(active) = settings.profiles.active_profile.as_deref().and_then(|n| self.get(n)) {
if let Some(active) = self.active_profile.as_deref().and_then(|n| self.profile(n)) {
profile.params = active.params.clone();
profile.metrics = active.metrics.clone();
profile.dashboards = active.dashboards.clone();
}
self.profiles.push(profile);
self.save(store_path)?;
if !settings.profiles.names.contains(&name) {
settings.profiles.names.push(name);
}
settings.save(settings_path)?;
self.save(settings_path)?;
Ok(())
}
/// Deletes the profile named `name` (persisting store and settings), clearing `active_profile` if it was active. Fails with `NotFound`, or `CannotDeleteDefault` for the `Default` profile.
pub fn delete(
&mut self,
store_path: &Path,
settings: &mut Settings,
settings_path: &Path,
name: &str,
) -> Result<(), ProfileError> {
/// Deletes the profile named `name`, persisting settings, clearing `active_profile` if it was
/// active. Fails with `NotFound`, or `CannotDeleteDefault` for the `Default` profile.
pub fn delete_profile(&mut self, settings_path: &Path, name: &str) -> Result<(), ProfileError> {
if name == DEFAULT_PROFILE_NAME {
return Err(ProfileError::CannotDeleteDefault);
}
let index = self.profiles.iter().position(|p| p.name == name).ok_or(ProfileError::NotFound)?;
self.profiles.remove(index);
self.save(store_path)?;
settings.profiles.names.retain(|n| n != name);
if settings.profiles.active_profile.as_deref() == Some(name) {
settings.profiles.active_profile = None;
if self.active_profile.as_deref() == Some(name) {
self.active_profile = None;
}
settings.save(settings_path)?;
self.save(settings_path)?;
Ok(())
}
/// Ensures a `Default` profile exists (seeding a starter dashboard/metric only on first creation), is tracked in `settings.profiles.names`, and falls back to active if nothing else is. Called once on startup before anything else touches profiles.
pub fn ensure_default(
&mut self,
store_path: &Path,
settings: &mut Settings,
settings_path: &Path,
) -> Result<(), ProfileError> {
if self.get(DEFAULT_PROFILE_NAME).is_none() {
/// Ensures a `Default` profile exists (seeding a starter dashboard/metric only on first
/// creation), and falls back to active if nothing else is. Called once on startup before anything
/// else touches profiles.
pub fn ensure_default_profile(&mut self, settings_path: &Path) -> Result<(), ProfileError> {
if self.profile(DEFAULT_PROFILE_NAME).is_none() {
let mut profile = Profile::new(DEFAULT_PROFILE_NAME.to_string());
profile.metrics.push(seeded_error_split_metric());
profile.dashboards.push(seeded_default_dashboard());
self.profiles.push(profile);
self.save(store_path)?;
}
if !settings.profiles.names.iter().any(|n| n == DEFAULT_PROFILE_NAME) {
settings.profiles.names.push(DEFAULT_PROFILE_NAME.to_string());
if self.active_profile.is_none() {
self.active_profile = Some(DEFAULT_PROFILE_NAME.to_string());
}
if settings.profiles.active_profile.is_none() {
settings.profiles.active_profile = Some(DEFAULT_PROFILE_NAME.to_string());
}
settings.save(settings_path)?;
self.save(settings_path)?;
Ok(())
}
/// Sets (or clears, with `None`) the active profile in `settings`. Fails with `NotFound` if `name` is `Some` and no such profile exists. Doesn't itself reload declarations into a running engine — that happens on next startup via `load_into_engine`.
pub fn set_active(
&self,
settings: &mut Settings,
/// Sets (or clears, with `None`) the active profile. Fails with `NotFound` if `name` is `Some`
/// and no such profile exists. Doesn't itself reload declarations into a running engine — that
/// happens on next startup via `load_into_engine`.
pub fn set_active_profile(
&mut self,
settings_path: &Path,
name: Option<String>,
) -> Result<(), ProfileError> {
if let Some(name) = &name {
if self.get(name).is_none() {
if self.profile(name).is_none() {
return Err(ProfileError::NotFound);
}
}
settings.profiles.active_profile = name;
settings.save(settings_path)?;
self.active_profile = name;
self.save(settings_path)?;
Ok(())
}
/// Records/updates a declared param into `profile_name`'s stored declarations (matched by param name), persisting the store. Intended to be called by the HTTP API alongside declaring against the engine, so declarations survive a restart.
/// Records/updates a declared param into `profile_name`'s stored declarations (matched by param
/// name), persisting settings. Intended to be called by the HTTP API alongside declaring against
/// the engine, so declarations survive a restart.
pub fn record_param_declaration(
&mut self,
store_path: &Path,
settings_path: &Path,
profile_name: &str,
request: &DeclareParamRequest,
) -> Result<(), ProfileError> {
@@ -532,14 +473,15 @@ impl ProfileStore {
Some(existing) => *existing = record,
None => profile.params.push(record),
}
self.save(store_path)?;
self.save(settings_path)?;
Ok(())
}
/// Records/updates a declared metric into `profile_name`'s stored declarations (matched by metric name), persisting the store; same integration intent as `record_param_declaration`.
/// Records/updates a declared metric into `profile_name`'s stored declarations (matched by metric
/// name), persisting settings; same integration intent as `record_param_declaration`.
pub fn record_metric_declaration(
&mut self,
store_path: &Path,
settings_path: &Path,
profile_name: &str,
request: &DeclareMetricRequest,
) -> Result<(), ProfileError> {
@@ -553,14 +495,16 @@ impl ProfileStore {
Some(existing) => *existing = record,
None => profile.metrics.push(record),
}
self.save(store_path)?;
self.save(settings_path)?;
Ok(())
}
/// Same as `record_metric_declaration`, for an in-place edit that may rename the metric: drops any persisted record under `old_name` first (when it differs from the new name) so a rename doesn't leave a stale duplicate declaration behind, then upserts by the new name as usual.
/// Same as `record_metric_declaration`, for an in-place edit that may rename the metric: drops
/// any persisted record under `old_name` first (when it differs from the new name) so a rename
/// doesn't leave a stale duplicate declaration behind, then upserts by the new name as usual.
pub fn record_metric_declaration_update(
&mut self,
store_path: &Path,
settings_path: &Path,
profile_name: &str,
old_name: &str,
request: &DeclareMetricRequest,
@@ -578,20 +522,21 @@ impl ProfileStore {
Some(existing) => *existing = record,
None => profile.metrics.push(record),
}
self.save(store_path)?;
self.save(settings_path)?;
Ok(())
}
/// Lists the dashboards saved on `profile_name`; fails with `NotFound` if it doesn't exist.
pub fn list_dashboards(&self, profile_name: &str) -> Result<&[Dashboard], ProfileError> {
let profile = self.get(profile_name).ok_or(ProfileError::NotFound)?;
let profile = self.profile(profile_name).ok_or(ProfileError::NotFound)?;
Ok(&profile.dashboards)
}
/// Creates a new, empty dashboard named `name` on `profile_name`, persisting the store; fails with `NotFound` if the profile doesn't exist.
/// Creates a new, empty dashboard named `name` on `profile_name`, persisting settings; fails
/// with `NotFound` if the profile doesn't exist.
pub fn create_dashboard(
&mut self,
store_path: &Path,
settings_path: &Path,
profile_name: &str,
name: String,
) -> Result<Dashboard, ProfileError> {
@@ -602,14 +547,16 @@ impl ProfileStore {
.ok_or(ProfileError::NotFound)?;
let dashboard = Dashboard::new(name);
profile.dashboards.push(dashboard.clone());
self.save(store_path)?;
self.save(settings_path)?;
Ok(dashboard)
}
/// Replaces the dashboard identified by `dashboard_id` with `dashboard`, persisting the store; `dashboard.id` is forced to `dashboard_id` so a caller can't change a dashboard's identity through this call.
/// Replaces the dashboard identified by `dashboard_id` with `dashboard`, persisting settings;
/// `dashboard.id` is forced to `dashboard_id` so a caller can't change a dashboard's identity
/// through this call.
pub fn save_dashboard(
&mut self,
store_path: &Path,
settings_path: &Path,
profile_name: &str,
dashboard_id: &str,
mut dashboard: Dashboard,
@@ -626,14 +573,15 @@ impl ProfileStore {
.ok_or(ProfileError::NotFound)?;
dashboard.id = dashboard_id.to_string();
*existing = dashboard.clone();
self.save(store_path)?;
self.save(settings_path)?;
Ok(dashboard)
}
/// Deletes the dashboard identified by `dashboard_id` from `profile_name`, persisting the store; fails with `NotFound` if either doesn't exist.
/// Deletes the dashboard identified by `dashboard_id` from `profile_name`, persisting settings;
/// fails with `NotFound` if either doesn't exist.
pub fn delete_dashboard(
&mut self,
store_path: &Path,
settings_path: &Path,
profile_name: &str,
dashboard_id: &str,
) -> Result<(), ProfileError> {
@@ -648,7 +596,7 @@ impl ProfileStore {
.position(|d| d.id == dashboard_id)
.ok_or(ProfileError::NotFound)?;
profile.dashboards.remove(index);
self.save(store_path)?;
self.save(settings_path)?;
Ok(())
}
}
@@ -694,92 +642,64 @@ mod tests {
response.await.unwrap()
}
#[test]
fn resolve_profiles_dir_is_relative_to_settings_parent() {
let settings_path = Path::new("/config/ottyl/settings.json");
let mut settings = Settings::default();
settings.profiles.profiles_dir = "profiles".to_string();
let dir = resolve_profiles_dir(&settings, settings_path);
assert_eq!(dir, Path::new("/config/ottyl/profiles"));
}
#[test]
fn resolve_profiles_dir_respects_absolute_path() {
let settings_path = Path::new("/config/ottyl/settings.json");
let mut settings = Settings::default();
settings.profiles.profiles_dir = "/elsewhere/profiles".to_string();
let dir = resolve_profiles_dir(&settings, settings_path);
assert_eq!(dir, Path::new("/elsewhere/profiles"));
}
#[test]
fn create_list_get_delete_round_trip() {
let dir = tempfile::tempdir().unwrap();
let store_path = dir.path().join("profiles.json");
let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default();
let mut store = ProfileStore::load(&store_path);
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
store.create(&store_path, &mut settings, &settings_path, "worker".to_string()).unwrap();
settings.create_profile(&settings_path, "web".to_string()).unwrap();
settings.create_profile(&settings_path, "worker".to_string()).unwrap();
assert_eq!(store.list().len(), 2);
assert!(store.get("web").is_some());
assert_eq!(settings.profiles.names, vec!["web".to_string(), "worker".to_string()]);
assert_eq!(settings.profiles.len(), 2);
assert!(settings.profile("web").is_some());
let dup = store.create(&store_path, &mut settings, &settings_path, "web".to_string());
let dup = settings.create_profile(&settings_path, "web".to_string());
assert!(matches!(dup, Err(ProfileError::DuplicateName)));
// Reload from disk: profiles and settings both persisted.
let reloaded = ProfileStore::load(&store_path);
assert_eq!(reloaded.list().len(), 2);
// Reload from disk: profiles persisted.
let reloaded_settings = Settings::load(&settings_path);
assert_eq!(reloaded_settings.profiles.names, settings.profiles.names);
assert_eq!(reloaded_settings.profiles.len(), 2);
assert!(reloaded_settings.profile("web").is_some());
assert!(reloaded_settings.profile("worker").is_some());
store.delete(&store_path, &mut settings, &settings_path, "worker").unwrap();
assert!(store.get("worker").is_none());
assert_eq!(settings.profiles.names, vec!["web".to_string()]);
settings.delete_profile(&settings_path, "worker").unwrap();
assert!(settings.profile("worker").is_none());
assert_eq!(settings.profiles.len(), 1);
let missing = store.delete(&store_path, &mut settings, &settings_path, "worker");
let missing = settings.delete_profile(&settings_path, "worker");
assert!(matches!(missing, Err(ProfileError::NotFound)));
}
#[test]
fn ensure_default_creates_default_and_activates_it_once() {
let dir = tempfile::tempdir().unwrap();
let store_path = dir.path().join("profiles.json");
let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default();
let mut store = ProfileStore::load(&store_path);
store.ensure_default(&store_path, &mut settings, &settings_path).unwrap();
settings.ensure_default_profile(&settings_path).unwrap();
assert!(store.get(DEFAULT_PROFILE_NAME).is_some());
assert!(settings.profiles.names.contains(&DEFAULT_PROFILE_NAME.to_string()));
assert_eq!(settings.profiles.active_profile, Some(DEFAULT_PROFILE_NAME.to_string()));
assert!(settings.profile(DEFAULT_PROFILE_NAME).is_some());
assert_eq!(settings.active_profile, Some(DEFAULT_PROFILE_NAME.to_string()));
// Idempotent: doesn't duplicate the profile or clobber an already-active (non-Default) profile.
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap();
store.ensure_default(&store_path, &mut settings, &settings_path).unwrap();
settings.create_profile(&settings_path, "web".to_string()).unwrap();
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
settings.ensure_default_profile(&settings_path).unwrap();
assert_eq!(store.list().iter().filter(|p| p.name == DEFAULT_PROFILE_NAME).count(), 1);
assert_eq!(settings.profiles.active_profile, Some("web".to_string()));
assert_eq!(settings.profiles.iter().filter(|p| p.name == DEFAULT_PROFILE_NAME).count(), 1);
assert_eq!(settings.active_profile, Some("web".to_string()));
}
#[test]
fn ensure_default_seeds_starter_dashboard_only_on_creation() {
let dir = tempfile::tempdir().unwrap();
let store_path = dir.path().join("profiles.json");
let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default();
let mut store = ProfileStore::load(&store_path);
store.ensure_default(&store_path, &mut settings, &settings_path).unwrap();
settings.ensure_default_profile(&settings_path).unwrap();
let default_profile = store.get(DEFAULT_PROFILE_NAME).unwrap();
let default_profile = settings.profile(DEFAULT_PROFILE_NAME).unwrap();
assert_eq!(default_profile.metrics.len(), 1);
assert_eq!(default_profile.metrics[0].name, SEEDED_METRIC_NAME);
assert_eq!(
@@ -800,9 +720,9 @@ mod tests {
assert!(dashboard.panels.iter().any(|p| p.chart_type == ChartType::Pie && p.promql == SEEDED_METRIC_NAME));
// Reload (simulating a restart) and ensure_default again: must not re-seed or duplicate.
let mut store = ProfileStore::load(&store_path);
store.ensure_default(&store_path, &mut settings, &settings_path).unwrap();
let default_profile = store.get(DEFAULT_PROFILE_NAME).unwrap();
let mut reloaded = Settings::load(&settings_path);
reloaded.ensure_default_profile(&settings_path).unwrap();
let default_profile = reloaded.profile(DEFAULT_PROFILE_NAME).unwrap();
assert_eq!(default_profile.metrics.len(), 1);
assert_eq!(default_profile.dashboards.len(), 1);
}
@@ -814,12 +734,10 @@ mod tests {
use tokio::sync::mpsc;
let dir = tempfile::tempdir().unwrap();
let store_path = dir.path().join("profiles.json");
let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default();
let mut store = ProfileStore::load(&store_path);
store.ensure_default(&store_path, &mut settings, &settings_path).unwrap();
let default_profile = store.get(DEFAULT_PROFILE_NAME).unwrap().clone();
settings.ensure_default_profile(&settings_path).unwrap();
let default_profile = settings.profile(DEFAULT_PROFILE_NAME).unwrap().clone();
let (engine_tx, engine_rx) = mpsc::channel(128);
let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone()));
@@ -857,41 +775,37 @@ mod tests {
#[test]
fn default_profile_cannot_be_deleted() {
let dir = tempfile::tempdir().unwrap();
let store_path = dir.path().join("profiles.json");
let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default();
let mut store = ProfileStore::load(&store_path);
store.ensure_default(&store_path, &mut settings, &settings_path).unwrap();
settings.ensure_default_profile(&settings_path).unwrap();
let err = store.delete(&store_path, &mut settings, &settings_path, DEFAULT_PROFILE_NAME);
let err = settings.delete_profile(&settings_path, DEFAULT_PROFILE_NAME);
assert!(matches!(err, Err(ProfileError::CannotDeleteDefault)));
assert!(store.get(DEFAULT_PROFILE_NAME).is_some());
assert!(settings.profile(DEFAULT_PROFILE_NAME).is_some());
}
#[test]
fn create_copies_the_active_profile() {
let dir = tempfile::tempdir().unwrap();
let store_path = dir.path().join("profiles.json");
let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default();
let mut store = ProfileStore::load(&store_path);
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
settings.create_profile(&settings_path, "web".to_string()).unwrap();
let param_req = param_request("level", r"level=(?P<level>\w+)", "level");
store.record_param_declaration(&store_path, "web", &param_req).unwrap();
store.create_dashboard(&store_path, "web", "Overview".to_string()).unwrap();
store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap();
settings.record_param_declaration(&settings_path, "web", &param_req).unwrap();
settings.create_dashboard(&settings_path, "web", "Overview".to_string()).unwrap();
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
store.create(&store_path, &mut settings, &settings_path, "web-copy".to_string()).unwrap();
let copy = store.get("web-copy").unwrap();
assert_eq!(copy.params, store.get("web").unwrap().params);
assert_eq!(copy.dashboards, store.get("web").unwrap().dashboards);
settings.create_profile(&settings_path, "web-copy".to_string()).unwrap();
let copy = settings.profile("web-copy").unwrap().clone();
assert_eq!(copy.params, settings.profile("web").unwrap().params);
assert_eq!(copy.dashboards, settings.profile("web").unwrap().dashboards);
// With no active profile, new profiles start empty as before.
store.set_active(&mut settings, &settings_path, None).unwrap();
store.create(&store_path, &mut settings, &settings_path, "blank".to_string()).unwrap();
let blank = store.get("blank").unwrap();
settings.set_active_profile(&settings_path, None).unwrap();
settings.create_profile(&settings_path, "blank".to_string()).unwrap();
let blank = settings.profile("blank").unwrap();
assert!(blank.params.is_empty());
assert!(blank.dashboards.is_empty());
}
@@ -899,44 +813,40 @@ mod tests {
#[test]
fn set_active_updates_and_persists_settings() {
let dir = tempfile::tempdir().unwrap();
let store_path = dir.path().join("profiles.json");
let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default();
let mut store = ProfileStore::load(&store_path);
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
store.create(&store_path, &mut settings, &settings_path, "worker".to_string()).unwrap();
settings.create_profile(&settings_path, "web".to_string()).unwrap();
settings.create_profile(&settings_path, "worker".to_string()).unwrap();
store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap();
assert_eq!(settings.profiles.active_profile, Some("web".to_string()));
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
assert_eq!(settings.active_profile, Some("web".to_string()));
// Switching to another known profile works and persists.
store.set_active(&mut settings, &settings_path, Some("worker".to_string())).unwrap();
settings.set_active_profile(&settings_path, Some("worker".to_string())).unwrap();
let reloaded = Settings::load(&settings_path);
assert_eq!(reloaded.profiles.active_profile, Some("worker".to_string()));
assert_eq!(reloaded.active_profile, Some("worker".to_string()));
// Setting an unknown profile active is rejected, leaving the previous value.
let err = store.set_active(&mut settings, &settings_path, Some("ghost".to_string()));
let err = settings.set_active_profile(&settings_path, Some("ghost".to_string()));
assert!(matches!(err, Err(ProfileError::NotFound)));
assert_eq!(settings.profiles.active_profile, Some("worker".to_string()));
assert_eq!(settings.active_profile, Some("worker".to_string()));
// Clearing the active profile works.
store.set_active(&mut settings, &settings_path, None).unwrap();
assert_eq!(settings.profiles.active_profile, None);
settings.set_active_profile(&settings_path, None).unwrap();
assert_eq!(settings.active_profile, None);
}
#[test]
fn record_param_and_metric_declarations_persist_into_profile() {
let dir = tempfile::tempdir().unwrap();
let store_path = dir.path().join("profiles.json");
let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default();
let mut store = ProfileStore::load(&store_path);
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
settings.create_profile(&settings_path, "web".to_string()).unwrap();
let param_req = param_request("level", r"level=(?P<level>\w+)", "level");
store.record_param_declaration(&store_path, "web", &param_req).unwrap();
settings.record_param_declaration(&settings_path, "web", &param_req).unwrap();
let metric_req = metric_request(
"requests",
@@ -944,9 +854,9 @@ mod tests {
"duration",
"duration",
);
store.record_metric_declaration(&store_path, "web", &metric_req).unwrap();
settings.record_metric_declaration(&settings_path, "web", &metric_req).unwrap();
let profile = store.get("web").unwrap();
let profile = settings.profile("web").unwrap();
assert_eq!(profile.params.len(), 1);
assert_eq!(profile.params[0].name, "level");
assert_eq!(profile.metrics.len(), 1);
@@ -954,39 +864,37 @@ mod tests {
// Re-declaring the same name updates in place rather than duplicating.
let param_req_v2 = param_request("level", r"lvl=(?P<level>\w+)", "level");
store.record_param_declaration(&store_path, "web", &param_req_v2).unwrap();
let profile = store.get("web").unwrap();
settings.record_param_declaration(&settings_path, "web", &param_req_v2).unwrap();
let profile = settings.profile("web").unwrap();
assert_eq!(profile.params.len(), 1);
assert_eq!(profile.params[0].message_match_regex, r"lvl=(?P<level>\w+)");
// Reload from disk: recorded declarations survive a restart.
let reloaded = ProfileStore::load(&store_path);
let profile = reloaded.get("web").unwrap();
let reloaded = Settings::load(&settings_path);
let profile = reloaded.profile("web").unwrap();
assert_eq!(profile.params.len(), 1);
assert_eq!(profile.metrics.len(), 1);
}
/// Acceptance criterion: declaring params/metrics, "restarting" (reloading the store from disk), and replaying into a fresh engine reproduces the same declarations and still ingests lines, without re-declaring by hand.
/// Acceptance criterion: declaring params/metrics, "restarting" (reloading settings from disk), and replaying into a fresh engine reproduces the same declarations and still ingests lines, without re-declaring by hand.
#[tokio::test]
async fn profile_round_trip_reproduces_declarations_in_fresh_engine() {
let dir = tempfile::tempdir().unwrap();
let store_path = dir.path().join("profiles.json");
let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default();
// Declare directly into a profile, as the HTTP API will do via the `record_*_declaration` hooks.
let mut store = ProfileStore::load(&store_path);
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
store
settings.create_profile(&settings_path, "web".to_string()).unwrap();
settings
.record_param_declaration(
&store_path,
&settings_path,
"web",
&param_request("level", r"level=(?P<level>\w+)", "level"),
)
.unwrap();
store
settings
.record_metric_declaration(
&store_path,
&settings_path,
"web",
&metric_request(
"requests",
@@ -996,15 +904,13 @@ mod tests {
),
)
.unwrap();
store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap();
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
// Simulate a restart: drop everything in memory, reload from disk.
drop(store);
drop(settings);
let settings = Settings::load(&settings_path);
let active_name = settings.profiles.active_profile.clone().unwrap();
let reloaded_store = ProfileStore::load(&store_path);
let profile = reloaded_store.get(&active_name).unwrap().clone();
let active_name = settings.active_profile.clone().unwrap();
let profile = settings.profile(&active_name).unwrap().clone();
// Spawn a fresh engine and replay the profile's declarations into it.
let (engine_tx, engine_rx) = mpsc::channel(16);
@@ -1061,18 +967,16 @@ mod tests {
assert_eq!(logs[0].text, "level=info request duration=42");
}
/// Acceptance criterion: a dashboard (with a panel) survives reloading the profile store from disk.
/// Acceptance criterion: a dashboard (with a panel) survives reloading settings from disk.
#[test]
fn dashboard_create_save_delete_round_trip_and_persists_across_reload() {
let dir = tempfile::tempdir().unwrap();
let store_path = dir.path().join("profiles.json");
let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default();
let mut store = ProfileStore::load(&store_path);
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
settings.create_profile(&settings_path, "web".to_string()).unwrap();
let dashboard = store.create_dashboard(&store_path, "web", "Overview".to_string()).unwrap();
let dashboard = settings.create_dashboard(&settings_path, "web", "Overview".to_string()).unwrap();
assert_eq!(dashboard.name, "Overview");
assert!(dashboard.panels.is_empty());
@@ -1083,15 +987,15 @@ mod tests {
promql: r#"requests{path="/a"}"#.to_string(),
chart_type: ChartType::Line,
});
let saved = store
.save_dashboard(&store_path, "web", &dashboard.id, with_panel)
let saved = settings
.save_dashboard(&settings_path, "web", &dashboard.id, with_panel)
.unwrap();
assert_eq!(saved.panels.len(), 1);
assert_eq!(saved.id, dashboard.id);
// Simulate a restart: drop and reload from disk.
drop(store);
let reloaded = ProfileStore::load(&store_path);
drop(settings);
let mut reloaded = Settings::load(&settings_path);
let dashboards = reloaded.list_dashboards("web").unwrap();
assert_eq!(dashboards.len(), 1);
assert_eq!(dashboards[0].name, "Overview");
@@ -1099,10 +1003,9 @@ mod tests {
assert_eq!(dashboards[0].panels[0].promql, r#"requests{path="/a"}"#);
// Deleting removes it, and that too persists.
let mut reloaded = reloaded;
reloaded.delete_dashboard(&store_path, "web", &dashboard.id).unwrap();
reloaded.delete_dashboard(&settings_path, "web", &dashboard.id).unwrap();
assert!(reloaded.list_dashboards("web").unwrap().is_empty());
let reloaded_again = ProfileStore::load(&store_path);
let reloaded_again = Settings::load(&settings_path);
assert!(reloaded_again.list_dashboards("web").unwrap().is_empty());
// Unknown profile/dashboard both fail with NotFound.