1use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17
18const VERSION: u32 = 1;
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23pub struct Cache {
24 #[serde(default)]
25 pub version: u32,
26 #[serde(default)]
28 pub queues: BTreeMap<String, Vec<String>>,
29}
30
31#[must_use]
34pub fn path_for(config_file: &Path) -> PathBuf {
35 config_file.with_file_name("queues.json")
36}
37
38impl Cache {
39 #[must_use]
42 pub fn load(path: &Path) -> Self {
43 let Ok(text) = std::fs::read_to_string(path) else {
44 return Self::default();
45 };
46 match serde_json::from_str::<Self>(&text) {
47 Ok(cache) if cache.version == VERSION => cache,
48 Ok(_) => {
49 tracing::debug!("ignoring a queue cache written by another version");
50 Self::default()
51 }
52 Err(error) => {
53 tracing::debug!(%error, "ignoring an unreadable queue cache");
54 Self::default()
55 }
56 }
57 }
58
59 pub fn record(&mut self, profile: &str, queue_keys: &[String]) {
64 for profiles in self.queues.values_mut() {
65 profiles.retain(|known| known != profile);
66 }
67
68 for key in queue_keys {
69 let profiles = self.queues.entry(key.clone()).or_default();
70 profiles.push(profile.to_owned());
71 profiles.sort();
72 profiles.dedup();
73 }
74
75 self.queues.retain(|_, profiles| !profiles.is_empty());
76 }
77
78 #[must_use]
83 pub fn profiles_for(&self, queue: &str, configured: &[String]) -> Vec<String> {
84 let mut owners: Vec<String> = self
85 .queues
86 .get(queue)
87 .map(|profiles| {
88 profiles
89 .iter()
90 .filter(|profile| configured.iter().any(|known| known == *profile))
91 .cloned()
92 .collect()
93 })
94 .unwrap_or_default();
95
96 owners.sort();
99 owners.dedup();
100 owners
101 }
102
103 pub fn save(&mut self, path: &Path) {
105 self.version = VERSION;
106 let Ok(text) = serde_json::to_string_pretty(self) else {
107 return;
108 };
109 if let Some(parent) = path.parent() {
110 let _ = std::fs::create_dir_all(parent);
111 }
112 if let Err(error) = std::fs::write(path, text) {
113 tracing::debug!(%error, "could not write the queue cache");
114 }
115 }
116}
117
118#[must_use]
120pub fn queue_of(key: &str) -> Option<&str> {
121 let (queue, number) = key.rsplit_once('-')?;
122 if queue.is_empty() || number.is_empty() || !number.bytes().all(|b| b.is_ascii_digit()) {
123 return None;
124 }
125 Some(queue)
126}
127
128#[cfg(test)]
129#[allow(clippy::expect_used)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn a_queue_key_is_the_part_before_the_number() {
135 assert_eq!(queue_of("LMS-12"), Some("LMS"));
136 assert_eq!(queue_of("TWO-PART-3"), Some("TWO-PART"));
137 }
138
139 #[test]
140 fn something_that_is_not_an_issue_key_has_no_queue() {
141 assert_eq!(queue_of("LMS"), None);
142 assert_eq!(queue_of("LMS-"), None);
143 assert_eq!(queue_of("LMS-abc"), None);
144 }
145
146 #[test]
147 fn recording_a_profile_replaces_what_it_used_to_see() {
148 let mut cache = Cache::default();
149 cache.record("work", &["LMS".to_owned(), "OLD".to_owned()]);
150 cache.record("work", &["LMS".to_owned()]);
151
152 assert_eq!(cache.profiles_for("LMS", &["work".to_owned()]), ["work"]);
153 assert!(cache.profiles_for("OLD", &["work".to_owned()]).is_empty());
154 }
155
156 #[test]
157 fn two_profiles_seeing_one_queue_are_both_reported() {
158 let mut cache = Cache::default();
159 cache.record("work", &["LMS".to_owned()]);
160 cache.record("personal", &["LMS".to_owned()]);
161
162 let configured = vec!["work".to_owned(), "personal".to_owned()];
163 assert_eq!(cache.profiles_for("LMS", &configured), ["personal", "work"]);
164 }
165
166 #[test]
169 fn a_profile_no_longer_configured_is_ignored() {
170 let mut cache = Cache::default();
171 cache.record("work", &["LMS".to_owned()]);
172 cache.record("gone", &["LMS".to_owned()]);
173
174 assert_eq!(cache.profiles_for("LMS", &["work".to_owned()]), ["work"]);
175 }
176
177 #[test]
178 fn an_unreadable_cache_is_simply_empty() {
179 let dir = tempfile::tempdir().expect("temp dir");
180 let path = dir.path().join("queues.json");
181 std::fs::write(&path, "not json").expect("write");
182
183 assert!(Cache::load(&path).queues.is_empty());
184 }
185
186 #[test]
187 fn a_saved_cache_round_trips() {
188 let dir = tempfile::tempdir().expect("temp dir");
189 let path = dir.path().join("queues.json");
190
191 let mut cache = Cache::default();
192 cache.record("work", &["LMS".to_owned()]);
193 cache.save(&path);
194
195 assert_eq!(
196 Cache::load(&path).profiles_for("LMS", &["work".to_owned()]),
197 ["work"]
198 );
199 }
200}