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 #[must_use]
104 pub fn profiles_for(&self, queue: &str, configured: &[String]) -> Vec<String> {
105 let mut owners: Vec<String> = self
106 .queues
107 .get(queue)
108 .map(|profiles| {
109 profiles
110 .iter()
111 .filter(|profile| configured.iter().any(|known| known == *profile))
112 .cloned()
113 .collect()
114 })
115 .unwrap_or_default();
116
117 owners.sort();
120 owners.dedup();
121 owners
122 }
123
124 pub fn save(&mut self, path: &Path) {
126 self.version = VERSION;
127 let Ok(text) = serde_json::to_string_pretty(self) else {
128 return;
129 };
130 if let Some(parent) = path.parent() {
131 let _ = std::fs::create_dir_all(parent);
132 }
133 if let Err(error) = std::fs::write(path, text) {
134 tracing::debug!(%error, "could not write the queue cache");
135 }
136 }
137}
138
139#[must_use]
141pub fn queue_of(key: &str) -> Option<&str> {
142 let (queue, number) = key.rsplit_once('-')?;
143 if queue.is_empty() || number.is_empty() || !number.bytes().all(|b| b.is_ascii_digit()) {
144 return None;
145 }
146 Some(queue)
147}
148
149#[cfg(test)]
150#[allow(clippy::expect_used)]
151mod tests {
152 use super::*;
153
154 #[test]
157 fn renaming_a_profile_carries_its_queues_over() {
158 let mut cache = Cache::default();
159 cache.record("work", &["LMS".to_owned(), "PROJ".to_owned()]);
160
161 assert!(cache.rename("work", "prod"));
162 assert_eq!(
163 cache.profiles_for("LMS", &["prod".to_owned()]),
164 vec!["prod".to_owned()]
165 );
166 assert!(!cache.rename("work", "prod"), "nothing left to move");
167 }
168
169 #[test]
170 fn a_queue_key_is_the_part_before_the_number() {
171 assert_eq!(queue_of("LMS-12"), Some("LMS"));
172 assert_eq!(queue_of("TWO-PART-3"), Some("TWO-PART"));
173 }
174
175 #[test]
176 fn something_that_is_not_an_issue_key_has_no_queue() {
177 assert_eq!(queue_of("LMS"), None);
178 assert_eq!(queue_of("LMS-"), None);
179 assert_eq!(queue_of("LMS-abc"), None);
180 }
181
182 #[test]
183 fn recording_a_profile_replaces_what_it_used_to_see() {
184 let mut cache = Cache::default();
185 cache.record("work", &["LMS".to_owned(), "OLD".to_owned()]);
186 cache.record("work", &["LMS".to_owned()]);
187
188 assert_eq!(cache.profiles_for("LMS", &["work".to_owned()]), ["work"]);
189 assert!(cache.profiles_for("OLD", &["work".to_owned()]).is_empty());
190 }
191
192 #[test]
193 fn two_profiles_seeing_one_queue_are_both_reported() {
194 let mut cache = Cache::default();
195 cache.record("work", &["LMS".to_owned()]);
196 cache.record("personal", &["LMS".to_owned()]);
197
198 let configured = vec!["work".to_owned(), "personal".to_owned()];
199 assert_eq!(cache.profiles_for("LMS", &configured), ["personal", "work"]);
200 }
201
202 #[test]
205 fn a_profile_no_longer_configured_is_ignored() {
206 let mut cache = Cache::default();
207 cache.record("work", &["LMS".to_owned()]);
208 cache.record("gone", &["LMS".to_owned()]);
209
210 assert_eq!(cache.profiles_for("LMS", &["work".to_owned()]), ["work"]);
211 }
212
213 #[test]
214 fn an_unreadable_cache_is_simply_empty() {
215 let dir = tempfile::tempdir().expect("temp dir");
216 let path = dir.path().join("queues.json");
217 std::fs::write(&path, "not json").expect("write");
218
219 assert!(Cache::load(&path).queues.is_empty());
220 }
221
222 #[test]
223 fn a_saved_cache_round_trips() {
224 let dir = tempfile::tempdir().expect("temp dir");
225 let path = dir.path().join("queues.json");
226
227 let mut cache = Cache::default();
228 cache.record("work", &["LMS".to_owned()]);
229 cache.save(&path);
230
231 assert_eq!(
232 Cache::load(&path).profiles_for("LMS", &["work".to_owned()]),
233 ["work"]
234 );
235 }
236}