use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const VERSION: u32 = 1;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Cache {
#[serde(default)]
pub version: u32,
#[serde(default)]
pub queues: BTreeMap<String, Vec<String>>,
}
#[must_use]
pub fn path_for(config_file: &Path) -> PathBuf {
config_file.with_file_name("queues.json")
}
impl Cache {
#[must_use]
pub fn load(path: &Path) -> Self {
let Ok(text) = std::fs::read_to_string(path) else {
return Self::default();
};
match serde_json::from_str::<Self>(&text) {
Ok(cache) if cache.version == VERSION => cache,
Ok(_) => {
tracing::debug!("ignoring a queue cache written by another version");
Self::default()
}
Err(error) => {
tracing::debug!(%error, "ignoring an unreadable queue cache");
Self::default()
}
}
}
pub fn record(&mut self, profile: &str, queue_keys: &[String]) {
for profiles in self.queues.values_mut() {
profiles.retain(|known| known != profile);
}
for key in queue_keys {
let profiles = self.queues.entry(key.clone()).or_default();
profiles.push(profile.to_owned());
profiles.sort();
profiles.dedup();
}
self.queues.retain(|_, profiles| !profiles.is_empty());
}
#[must_use]
pub fn profiles_for(&self, queue: &str, configured: &[String]) -> Vec<String> {
let mut owners: Vec<String> = self
.queues
.get(queue)
.map(|profiles| {
profiles
.iter()
.filter(|profile| configured.iter().any(|known| known == *profile))
.cloned()
.collect()
})
.unwrap_or_default();
owners.sort();
owners.dedup();
owners
}
pub fn save(&mut self, path: &Path) {
self.version = VERSION;
let Ok(text) = serde_json::to_string_pretty(self) else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(error) = std::fs::write(path, text) {
tracing::debug!(%error, "could not write the queue cache");
}
}
}
#[must_use]
pub fn queue_of(key: &str) -> Option<&str> {
let (queue, number) = key.rsplit_once('-')?;
if queue.is_empty() || number.is_empty() || !number.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some(queue)
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn a_queue_key_is_the_part_before_the_number() {
assert_eq!(queue_of("LMS-12"), Some("LMS"));
assert_eq!(queue_of("TWO-PART-3"), Some("TWO-PART"));
}
#[test]
fn something_that_is_not_an_issue_key_has_no_queue() {
assert_eq!(queue_of("LMS"), None);
assert_eq!(queue_of("LMS-"), None);
assert_eq!(queue_of("LMS-abc"), None);
}
#[test]
fn recording_a_profile_replaces_what_it_used_to_see() {
let mut cache = Cache::default();
cache.record("work", &["LMS".to_owned(), "OLD".to_owned()]);
cache.record("work", &["LMS".to_owned()]);
assert_eq!(cache.profiles_for("LMS", &["work".to_owned()]), ["work"]);
assert!(cache.profiles_for("OLD", &["work".to_owned()]).is_empty());
}
#[test]
fn two_profiles_seeing_one_queue_are_both_reported() {
let mut cache = Cache::default();
cache.record("work", &["LMS".to_owned()]);
cache.record("personal", &["LMS".to_owned()]);
let configured = vec!["work".to_owned(), "personal".to_owned()];
assert_eq!(cache.profiles_for("LMS", &configured), ["personal", "work"]);
}
#[test]
fn a_profile_no_longer_configured_is_ignored() {
let mut cache = Cache::default();
cache.record("work", &["LMS".to_owned()]);
cache.record("gone", &["LMS".to_owned()]);
assert_eq!(cache.profiles_for("LMS", &["work".to_owned()]), ["work"]);
}
#[test]
fn an_unreadable_cache_is_simply_empty() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("queues.json");
std::fs::write(&path, "not json").expect("write");
assert!(Cache::load(&path).queues.is_empty());
}
#[test]
fn a_saved_cache_round_trips() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("queues.json");
let mut cache = Cache::default();
cache.record("work", &["LMS".to_owned()]);
cache.save(&path);
assert_eq!(
Cache::load(&path).profiles_for("LMS", &["work".to_owned()]),
["work"]
);
}
}