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);