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 pub fn rename(&mut self, from: &str, to: &str) -> bool {
85 let mut moved = false;
86 for profiles in self.queues.values_mut() {
87 for profile in profiles.iter_mut() {
88 if profile == from {
89 to.clone_into(profile);
90 moved = true;
91 }
92 }
93 profiles.sort();
94 profiles.dedup();
95 }
96 moved
97 }
98
99 pub fn forget(&mut self, profile: &str) -> bool {
106 let mut dropped = false;
107 for profiles in self.queues.values_mut() {
108 let before = profiles.len();
109 profiles.retain(|known| known != profile);
110 dropped |= profiles.len() != before;
111 }
112 self.queues.retain(|_, profiles| !profiles.is_empty());
113 dropped
114 }
115
116 #[must_use]
121 pub fn profiles_for(&self, queue: &str, configured: &[String]) -> Vec<String> {
122 let mut owners: Vec<String> = self
123 .queues
124 .get(queue)
125 .map(|profiles| {
126 profiles
127 .iter()
128 .filter(|profile| configured.iter().any(|known| known == *profile))
129 .cloned()
130 .collect()
131 })
132 .unwrap_or_default();
133
134 owners.sort();
137 owners.dedup();
138 owners
139 }
140
141 pub fn save(&mut self, path: &Path) {
143 self.version = VERSION;
144 let Ok(text) = serde_json::to_string_pretty(self) else {
145 return;
146 };
147 if let Some(parent) = path.parent() {
148 let _ = std::fs::create_dir_all(parent);
149 }
150 if let Err(error) = std::fs::write(path, text) {
151 tracing::debug!(%error, "could not write the queue cache");
152 }
153 }
154}
155
156#[must_use]
158pub fn queue_of(key: &str) -> Option<&str> {
159 let (queue, number) = key.rsplit_once('-')?;
160 if queue.is_empty() || number.is_empty() || !number.bytes().all(|b| b.is_ascii_digit()) {
161 return None;
162 }
163 Some(queue)
164}
165
166#[cfg(test)]
167#[allow(clippy::expect_used)]
168mod tests {
169 use super::*;
170
171 #[test]
174 fn renaming_a_profile_carries_its_queues_over() {
175 let mut cache = Cache::default();
176 cache.record("work", &["LMS".to_owned(), "PROJ".to_owned()]);
177
178 assert!(cache.rename("work", "prod"));
179 assert_eq!(
180 cache.profiles_for("LMS", &["prod".to_owned()]),
181 vec!["prod".to_owned()]
182 );
183 assert!(!cache.rename("work", "prod"), "nothing left to move");
184 }
185
186 #[test]
187 fn a_queue_key_is_the_part_before_the_number() {
188 assert_eq!(queue_of("LMS-12"), Some("LMS"));
189 assert_eq!(queue_of("TWO-PART-3"), Some("TWO-PART"));
190 }
191
192 #[test]
193 fn something_that_is_not_an_issue_key_has_no_queue() {
194 assert_eq!(queue_of("LMS"), None);
195 assert_eq!(queue_of("LMS-"), None);
196 assert_eq!(queue_of("LMS-abc"), None);
197 }
198
199 #[test]
200 fn recording_a_profile_replaces_what_it_used_to_see() {
201 let mut cache = Cache::default();
202 cache.record("work", &["LMS".to_owned(), "OLD".to_owned()]);
203 cache.record("work", &["LMS".to_owned()]);
204
205 assert_eq!(cache.profiles_for("LMS", &["work".to_owned()]), ["work"]);
206 assert!(cache.profiles_for("OLD", &["work".to_owned()]).is_empty());
207 }
208
209 #[test]
210 fn two_profiles_seeing_one_queue_are_both_reported() {
211 let mut cache = Cache::default();
212 cache.record("work", &["LMS".to_owned()]);
213 cache.record("personal", &["LMS".to_owned()]);
214
215 let configured = vec!["work".to_owned(), "personal".to_owned()];
216 assert_eq!(cache.profiles_for("LMS", &configured), ["personal", "work"]);
217 }
218
219 #[test]
222 fn a_profile_no_longer_configured_is_ignored() {
223 let mut cache = Cache::default();
224 cache.record("work", &["LMS".to_owned()]);
225 cache.record("gone", &["LMS".to_owned()]);
226
227 assert_eq!(cache.profiles_for("LMS", &["work".to_owned()]), ["work"]);
228 }
229
230 #[test]
231 fn an_unreadable_cache_is_simply_empty() {
232 let dir = tempfile::tempdir().expect("temp dir");
233 let path = dir.path().join("queues.json");
234 std::fs::write(&path, "not json").expect("write");
235
236 assert!(Cache::load(&path).queues.is_empty());
237 }
238
239 #[test]
240 fn a_saved_cache_round_trips() {
241 let dir = tempfile::tempdir().expect("temp dir");
242 let path = dir.path().join("queues.json");
243
244 let mut cache = Cache::default();
245 cache.record("work", &["LMS".to_owned()]);
246 cache.save(&path);
247
248 assert_eq!(
249 Cache::load(&path).profiles_for("LMS", &["work".to_owned()]),
250 ["work"]
251 );
252 }
253}