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    /// Which profiles see this queue, among those still configured.
100    ///
101    /// Filtering against the current config matters: a profile deleted from the
102    /// config must not keep making its old queues look ambiguous.
103    #[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        // Sorted so the "write x/KEY-1 or y/KEY-1" message reads the same every
118        // time, whatever order the cache file happened to be written in.
119        owners.sort();
120        owners.dedup();
121        owners
122    }
123
124    /// Write it out. Failing to save a cache is not worth failing a command for.
125    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/// The queue part of an issue key: `LMS` from `LMS-12`.
140#[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    /// A renamed profile keeps what was known about it: otherwise the next
155    /// bare key is ambiguous again until something lists queues afresh.
156    #[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    /// A profile removed from the config must stop making its old queues look
203    /// ambiguous.
204    #[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}