Docs, ticks, working app
This commit is contained in:
+3
-1
@@ -462,11 +462,13 @@ async fn update_settings(
|
||||
}
|
||||
|
||||
/// Maps a `ProfileError` to a response status/message: `NotFound` -> 404,
|
||||
/// `DuplicateName` -> 400, `Io` -> 500. Mirrors `css_error_response` above.
|
||||
/// `DuplicateName`/`CannotDeleteDefault` -> 400, `Io` -> 500. Mirrors
|
||||
/// `css_error_response` above.
|
||||
fn profile_error_response(err: ProfileError) -> axum::response::Response {
|
||||
match err {
|
||||
ProfileError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(),
|
||||
ProfileError::DuplicateName => (StatusCode::BAD_REQUEST, err.to_string()).into_response(),
|
||||
ProfileError::CannotDeleteDefault => (StatusCode::BAD_REQUEST, err.to_string()).into_response(),
|
||||
ProfileError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
+7
-5
@@ -61,11 +61,13 @@ async fn main() {
|
||||
// could silently miss lines that raced ahead of the declarations).
|
||||
{
|
||||
let (active_profile_name, profiles_dir) = {
|
||||
let settings_guard = settings.lock().unwrap();
|
||||
(
|
||||
settings_guard.profiles.active_profile.clone(),
|
||||
profile::resolve_profiles_dir(&settings_guard, &settings_path),
|
||||
)
|
||||
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)
|
||||
.expect("failed to ensure the Default profile exists");
|
||||
(settings_guard.profiles.active_profile.clone(), profiles_dir)
|
||||
};
|
||||
if let Some(profile_name) = active_profile_name {
|
||||
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
||||
|
||||
+122
-3
@@ -45,6 +45,10 @@ 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`).
|
||||
pub const DEFAULT_PROFILE_NAME: &str = "Default";
|
||||
|
||||
/// Mirrors `data_value::MetricPrimitiveKind` with `Serialize`/`Deserialize` so
|
||||
/// profiles can round-trip through JSON. `MetricPrimitiveKind` itself only
|
||||
/// derives `Serialize` (it's an engine-internal type owned by tickets
|
||||
@@ -311,6 +315,9 @@ pub enum ProfileError {
|
||||
NotFound,
|
||||
/// A profile with the given name already exists.
|
||||
DuplicateName,
|
||||
/// The `Default` profile can't be deleted — there must always be at
|
||||
/// least one profile to fall back to.
|
||||
CannotDeleteDefault,
|
||||
/// Underlying I/O failure reading/writing the profile store or settings file.
|
||||
Io(io::Error),
|
||||
}
|
||||
@@ -320,6 +327,7 @@ impl fmt::Display for ProfileError {
|
||||
match self {
|
||||
ProfileError::NotFound => write!(f, "no profile with that name"),
|
||||
ProfileError::DuplicateName => write!(f, "a profile with that name already exists"),
|
||||
ProfileError::CannotDeleteDefault => write!(f, "the Default profile cannot be deleted"),
|
||||
ProfileError::Io(err) => write!(f, "I/O error: {err}"),
|
||||
}
|
||||
}
|
||||
@@ -411,11 +419,16 @@ impl ProfileStore {
|
||||
self.profiles.iter().find(|p| p.name == name)
|
||||
}
|
||||
|
||||
/// Creates a new, empty profile named `name`, persists the store to
|
||||
/// Creates a new profile named `name`, persists the store to
|
||||
/// `store_path`, and records the name in `settings.profiles.names`
|
||||
/// (persisting `settings` to `settings_path` too, keeping the two files in
|
||||
/// sync). Fails with `ProfileError::DuplicateName` if a profile with that
|
||||
/// name already exists.
|
||||
///
|
||||
/// The new profile starts as a copy of the currently active profile's
|
||||
/// params/metrics/dashboards (or empty, if none is active) — a preset is
|
||||
/// most useful as a variation on the one you were just using, not a
|
||||
/// blank slate.
|
||||
pub fn create(
|
||||
&mut self,
|
||||
store_path: &Path,
|
||||
@@ -426,7 +439,13 @@ impl ProfileStore {
|
||||
if self.get(&name).is_some() {
|
||||
return Err(ProfileError::DuplicateName);
|
||||
}
|
||||
self.profiles.push(Profile::new(name.clone()));
|
||||
let mut profile = Profile::new(name.clone());
|
||||
if let Some(active) = settings.profiles.active_profile.as_deref().and_then(|n| self.get(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) {
|
||||
@@ -440,7 +459,9 @@ impl ProfileStore {
|
||||
/// and drops it from `settings.profiles.names` (persisting `settings` to
|
||||
/// `settings_path` too). If the deleted profile was active, clears
|
||||
/// `settings.profiles.active_profile`. Fails with `ProfileError::NotFound`
|
||||
/// if no such profile exists.
|
||||
/// if no such profile exists, or `ProfileError::CannotDeleteDefault` for
|
||||
/// the `Default` profile — there must always be at least one profile to
|
||||
/// fall back to.
|
||||
pub fn delete(
|
||||
&mut self,
|
||||
store_path: &Path,
|
||||
@@ -448,6 +469,9 @@ impl ProfileStore {
|
||||
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)?;
|
||||
@@ -460,6 +484,35 @@ impl ProfileStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Guarantees a `Default` profile exists in this store (creating an empty
|
||||
/// one if not), that it's tracked in `settings.profiles.names`, and that
|
||||
/// `settings.profiles.active_profile` falls back to it when nothing else
|
||||
/// is active — so the app always has at least one usable profile, even
|
||||
/// on a first run with no settings file yet. Persists both the store and
|
||||
/// `settings` if either changed.
|
||||
///
|
||||
/// Called once on startup (see `main.rs`) 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() {
|
||||
self.profiles.push(Profile::new(DEFAULT_PROFILE_NAME.to_string()));
|
||||
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 settings.profiles.active_profile.is_none() {
|
||||
settings.profiles.active_profile = Some(DEFAULT_PROFILE_NAME.to_string());
|
||||
}
|
||||
settings.save(settings_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets (or clears, with `name: None`) the active profile: updates
|
||||
/// `settings.profiles.active_profile` and persists `settings` to
|
||||
/// `settings_path`. Fails with `ProfileError::NotFound` if `name` is
|
||||
@@ -718,6 +771,72 @@ mod tests {
|
||||
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();
|
||||
|
||||
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()));
|
||||
|
||||
// Idempotent: doesn't duplicate the profile, and doesn't 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();
|
||||
|
||||
assert_eq!(store.list().iter().filter(|p| p.name == DEFAULT_PROFILE_NAME).count(), 1);
|
||||
assert_eq!(settings.profiles.active_profile, Some("web".to_string()));
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
let err = store.delete(&store_path, &mut settings, &settings_path, DEFAULT_PROFILE_NAME);
|
||||
assert!(matches!(err, Err(ProfileError::CannotDeleteDefault)));
|
||||
assert!(store.get(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();
|
||||
let param_req = param_request("level", r"level=(?P<level>\w+)", "level");
|
||||
store.record_param_declaration(&store_path, "web", ¶m_req).unwrap();
|
||||
store.create_dashboard(&store_path, "web", "Overview".to_string()).unwrap();
|
||||
store.set_active(&mut settings, &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);
|
||||
|
||||
// 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();
|
||||
assert!(blank.params.is_empty());
|
||||
assert!(blank.dashboards.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_active_updates_and_persists_settings() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -22,6 +22,13 @@ use serde::{Deserialize, Serialize};
|
||||
pub enum ColorScheme {
|
||||
Light,
|
||||
Dark,
|
||||
Grey,
|
||||
Red,
|
||||
Green,
|
||||
Blue,
|
||||
Yellow,
|
||||
Pink,
|
||||
Orange,
|
||||
#[default]
|
||||
System,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user