Skip to main content

ytcli/config/
cache.rs

1//! What we have learned about the organisations, kept between runs.
2//!
3//! There is exactly one thing here and one reason for it. A bare `LMS-12` is
4//! ambiguous when two profiles can both see a queue called `LMS`, but finding
5//! that out costs a request per profile — far too much to pay on every command.
6//! So the map is recorded when something already had to list queues (`auth
7//! status`, `auth login`) and consulted for free afterwards.
8//!
9//! The cache is an optimisation and is treated as one: missing, stale or
10//! unreadable, the tool works exactly as it did before, it just cannot warn
11//! about a collision it has never seen.
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17
18/// Bumped when the shape changes, so an old file is ignored rather than
19/// misread.
20const VERSION: u32 = 1;
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23pub struct Cache {
24    #[serde(default)]
25    pub version: u32,
26    /// Queue key -> the profiles known to see it.
27    #[serde(default)]
28    pub queues: BTreeMap<String, Vec<String>>,
29}
30
31/// Where the cache lives: beside the config, not inside it. It is derived data,
32/// and nobody should have to read it or keep it in version control.
33#[must_use]
34pub fn path_for(config_file: &Path) -> PathBuf {
35    config_file.with_file_name("queues.json")
36}
37
38impl Cache {
39    /// Read it, or start empty. A cache that cannot be read is not an error:
40    /// the worst outcome is a warning we fail to give.
41    #[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    /// Replace what is known about one profile.
60    ///
61    /// Replace rather than merge: a queue the profile can no longer see should
62    /// stop being attributed to it, or the warning outlives the fact.
63    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    /// Follow a profile that was renamed.
79    ///
80    /// Stale names are already harmless — `profiles_for` filters against the
81    /// configured ones — but dropping the knowledge would make the next bare
82    /// key ambiguous again until something lists queues afresh. Returns whether
83    /// anything moved, so an unchanged cache is not rewritten.
84    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    /// Drop everything remembered about a profile that no longer exists.
100    ///
101    /// `profiles_for` already ignores names that are not configured, so this is
102    /// about a later profile reusing the name: it must not inherit the queues
103    /// its namesake could see. Returns whether anything went, so an unchanged
104    /// cache is not rewritten.
105    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    /// Which profiles see this queue, among those still configured.
117    ///
118    /// Filtering against the current config matters: a profile deleted from the
119    /// config must not keep making its old queues look ambiguous.
120    #[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        // Sorted so the "write x/KEY-1 or y/KEY-1" message reads the same every
135        // time, whatever order the cache file happened to be written in.
136        owners.sort();
137        owners.dedup();
138        owners
139    }
140
141    /// Write it out. Failing to save a cache is not worth failing a command for.
142    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/// The queue part of an issue key: `LMS` from `LMS-12`.
157#[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    /// A renamed profile keeps what was known about it: otherwise the next
172    /// bare key is ambiguous again until something lists queues afresh.
173    #[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    /// A profile removed from the config must stop making its old queues look
220    /// ambiguous.
221    #[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}