Docs, ticks, working app

This commit is contained in:
Hugh Kaznowski
2026-09-05 15:05:39 +01:00
parent 41362aa3d8
commit 51e577f01e
41 changed files with 1039 additions and 683 deletions
+122 -3
View File
@@ -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", &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();
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();