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    /// Which profiles see this queue, among those still configured.
79    ///
80    /// Filtering against the current config matters: a profile deleted from the
81    /// config must not keep making its old queues look ambiguous.
82    #[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        // Sorted so the "write x/KEY-1 or y/KEY-1" message reads the same every
97        // time, whatever order the cache file happened to be written in.
98        owners.sort();
99        owners.dedup();
100        owners
101    }
102
103    /// Write it out. Failing to save a cache is not worth failing a command for.
104    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/// The queue part of an issue key: `LMS` from `LMS-12`.
119#[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    /// A profile removed from the config must stop making its old queues look
167    /// ambiguous.
168    #[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}