Skip to main content

tempo_ext/
registry.rs

1//! Persistent registry of installed extensions (versions, update check timestamps).
2//!
3//! NOTE: load/save is not file-locked. Concurrent `tempo` invocations may
4//! lose a write (last-writer-wins). This is acceptable today because the
5//! data is limited to `checked_at` timestamps and `installed_version`
6//! strings — the worst outcome is a redundant update check.
7
8use serde::{Deserialize, Serialize};
9use std::{
10    collections::HashMap,
11    env, fs,
12    path::PathBuf,
13    time::{SystemTime, UNIX_EPOCH},
14};
15
16const UPDATE_CHECK_INTERVAL_SECS: u64 = 6 * 60 * 60; // 6 hours
17
18/// On-disk state for all known extensions, keyed by extension name.
19#[derive(Debug, Default, Serialize, Deserialize)]
20pub(crate) struct Registry {
21    /// Map from extension name (e.g. `"wallet"`) to its recorded state.
22    #[serde(default)]
23    pub(crate) extensions: HashMap<String, ExtensionState>,
24}
25
26/// Persisted metadata for a single extension.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub(crate) struct ExtensionState {
29    /// Unix timestamp (seconds) of the last update check.
30    pub(crate) checked_at: u64,
31    /// Version string recorded at install time (e.g. `"1.0.0"`).
32    pub(crate) installed_version: String,
33    /// When true, auto-update will not install newer versions — only
34    /// log that an update is available. Set when the user installs a
35    /// specific version via `tempo add <ext> <version>`.
36    #[serde(default)]
37    pub(crate) pinned: bool,
38    /// Short description from the release manifest.
39    #[serde(default)]
40    pub(crate) description: String,
41}
42
43impl Registry {
44    /// Loads the registry from disk.
45    ///
46    /// Returns `Ok(Self::default())` when the file does not exist or no data
47    /// directory can be determined. Returns an error if the file exists but
48    /// cannot be read or parsed — the caller should surface this to the user.
49    pub(crate) fn load() -> Result<Self, String> {
50        let Some(path) = state_path() else {
51            return Ok(Self::default());
52        };
53        let content = match fs::read_to_string(&path) {
54            Ok(c) => c,
55            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
56                return Ok(Self::default());
57            }
58            Err(_) => {
59                return Err(format!(
60                    "registry corrupt; to reset, run:\n  rm \"{}\"",
61                    path.display()
62                ));
63            }
64        };
65        serde_json::from_str(&content).map_err(|_| {
66            format!(
67                "registry corrupt; to reset, run:\n  rm \"{}\"",
68                path.display()
69            )
70        })
71    }
72
73    /// Persists the registry to disk via atomic rename.
74    pub(crate) fn save(&self) {
75        let path = match state_path() {
76            Some(p) => p,
77            None => return,
78        };
79        if let Some(parent) = path.parent() {
80            let _ = fs::create_dir_all(parent);
81        }
82        let json = match serde_json::to_string_pretty(self) {
83            Ok(j) => j,
84            Err(err) => {
85                tracing::warn!("registry serialize failed: {err}");
86                return;
87            }
88        };
89        let tmp = path.with_extension("tmp");
90        if let Err(err) = fs::write(&tmp, format!("{json}\n")) {
91            tracing::warn!("registry write failed: {}: {err}", tmp.display());
92            return;
93        }
94        if let Err(err) = fs::rename(&tmp, &path) {
95            tracing::warn!("registry rename failed: {}: {err}", path.display());
96        }
97    }
98
99    /// Returns `true` if the extension has never been checked or the last
100    /// check was more than 6 hours ago.
101    pub(crate) fn needs_update_check(&self, extension: &str) -> bool {
102        let now = now_secs();
103        match self.extensions.get(extension) {
104            Some(ext) => now.saturating_sub(ext.checked_at) >= UPDATE_CHECK_INTERVAL_SECS,
105            None => true,
106        }
107    }
108
109    /// Records a successful install or update check for an extension.
110    pub(crate) fn record_check(
111        &mut self,
112        extension: &str,
113        version: &str,
114        pinned: bool,
115        description: &str,
116    ) {
117        self.extensions.insert(
118            extension.to_string(),
119            ExtensionState {
120                checked_at: now_secs(),
121                installed_version: version.to_string(),
122                pinned,
123                description: description.to_string(),
124            },
125        );
126    }
127
128    /// Returns `true` if the extension is pinned to a specific version.
129    pub(crate) fn is_pinned(&self, extension: &str) -> bool {
130        self.extensions.get(extension).is_some_and(|e| e.pinned)
131    }
132
133    /// Bumps the check timestamp without changing the recorded version.
134    /// Used on network failure to avoid retrying every invocation.
135    pub(crate) fn touch_check(&mut self, extension: &str) {
136        if let Some(ext) = self.extensions.get_mut(extension) {
137            ext.checked_at = now_secs();
138        } else {
139            // No record at all — record with empty version so we don't
140            // keep retrying on every launch during an outage.
141            self.extensions.insert(
142                extension.to_string(),
143                ExtensionState {
144                    checked_at: now_secs(),
145                    installed_version: String::new(),
146                    pinned: false,
147                    description: String::new(),
148                },
149            );
150        }
151    }
152}
153
154fn now_secs() -> u64 {
155    SystemTime::now()
156        .duration_since(UNIX_EPOCH)
157        .map(|d| d.as_secs())
158        .unwrap_or(0)
159}
160
161/// Resolves the path to the registry file.
162///
163/// Uses `TEMPO_HOME/extensions.json` if set, otherwise the platform data
164/// directory via `dirs_next` (e.g. `~/Library/Application Support/tempo` on
165/// macOS, `$XDG_DATA_HOME/tempo` on Linux).
166fn state_path() -> Option<PathBuf> {
167    if let Some(home) = env::var_os("TEMPO_HOME") {
168        Some(PathBuf::from(home).join("extensions.json"))
169    } else {
170        dirs_next::data_dir().map(|data| data.join("tempo").join("extensions.json"))
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::test_util::ENV_MUTEX;
178
179    /// RAII guard that sets `TEMPO_HOME` to a temp directory and restores it
180    /// on drop. Must be held alongside `ENV_MUTEX`.
181    struct TempHome {
182        prev: Option<String>,
183        _tmp: tempfile::TempDir,
184    }
185
186    impl TempHome {
187        fn new() -> Self {
188            let tmp = tempfile::TempDir::new().unwrap();
189            let prev = std::env::var("TEMPO_HOME").ok();
190            unsafe { std::env::set_var("TEMPO_HOME", tmp.path()) };
191            Self { prev, _tmp: tmp }
192        }
193
194        fn registry_path(&self) -> PathBuf {
195            self._tmp.path().join("extensions.json")
196        }
197    }
198
199    impl Drop for TempHome {
200        fn drop(&mut self) {
201            match &self.prev {
202                Some(v) => unsafe { std::env::set_var("TEMPO_HOME", v) },
203                None => unsafe { std::env::remove_var("TEMPO_HOME") },
204            }
205        }
206    }
207
208    #[test]
209    fn load_returns_default_when_file_missing() {
210        let _lock = ENV_MUTEX.lock().unwrap();
211        let _home = TempHome::new();
212        let reg = Registry::load().unwrap();
213        assert!(reg.extensions.is_empty());
214    }
215
216    #[test]
217    fn load_returns_ok_for_valid_json() {
218        let _lock = ENV_MUTEX.lock().unwrap();
219        let home = TempHome::new();
220        let json = r#"{"extensions":{"wallet":{"checked_at":0,"installed_version":"1.0.0","pinned":false,"description":"test"}}}"#;
221        fs::write(home.registry_path(), json).unwrap();
222        let reg = Registry::load().unwrap();
223        assert_eq!(reg.extensions["wallet"].installed_version, "1.0.0");
224        assert_eq!(reg.extensions["wallet"].description, "test");
225    }
226
227    #[test]
228    fn load_returns_error_for_invalid_json() {
229        let _lock = ENV_MUTEX.lock().unwrap();
230        let home = TempHome::new();
231        fs::write(home.registry_path(), "NOT VALID JSON {{{").unwrap();
232        let err = Registry::load().unwrap_err();
233        assert!(
234            err.contains("registry corrupt"),
235            "expected 'registry corrupt', got: {err}"
236        );
237        assert!(err.contains("rm \""), "expected rm command, got: {err}");
238    }
239
240    #[test]
241    fn load_returns_error_for_unreadable_path() {
242        let _lock = ENV_MUTEX.lock().unwrap();
243        let home = TempHome::new();
244        // Create a directory where the file is expected — read_to_string
245        // will fail with a non-NotFound IO error.
246        fs::create_dir_all(home.registry_path()).unwrap();
247        let err = Registry::load().unwrap_err();
248        assert!(
249            err.contains("registry corrupt"),
250            "expected 'registry corrupt', got: {err}"
251        );
252    }
253
254    #[test]
255    fn load_error_message_contains_path() {
256        let _lock = ENV_MUTEX.lock().unwrap();
257        let home = TempHome::new();
258        fs::write(home.registry_path(), "garbage").unwrap();
259        let err = Registry::load().unwrap_err();
260        let expected_path = home.registry_path().display().to_string();
261        assert!(
262            err.contains(&expected_path),
263            "error should contain path '{expected_path}', got: {err}"
264        );
265    }
266
267    #[test]
268    fn save_then_load_roundtrip_on_disk() {
269        let _lock = ENV_MUTEX.lock().unwrap();
270        let _home = TempHome::new();
271        let mut reg = Registry::default();
272        reg.record_check("wallet", "2.0.0", true, "Tempo wallet");
273        reg.save();
274        let loaded = Registry::load().unwrap();
275        assert_eq!(loaded.extensions["wallet"].installed_version, "2.0.0");
276        assert!(loaded.is_pinned("wallet"));
277        assert_eq!(loaded.extensions["wallet"].description, "Tempo wallet");
278    }
279
280    #[test]
281    fn load_empty_file_returns_error() {
282        let _lock = ENV_MUTEX.lock().unwrap();
283        let home = TempHome::new();
284        fs::write(home.registry_path(), "").unwrap();
285        let err = Registry::load().unwrap_err();
286        assert!(
287            err.contains("registry corrupt"),
288            "empty file should be corrupt, got: {err}"
289        );
290    }
291
292    #[test]
293    fn load_partial_json_returns_error() {
294        let _lock = ENV_MUTEX.lock().unwrap();
295        let home = TempHome::new();
296        fs::write(home.registry_path(), r#"{"extensions":{"#).unwrap();
297        let err = Registry::load().unwrap_err();
298        assert!(err.contains("registry corrupt"));
299    }
300
301    #[test]
302    fn needs_check_when_no_record() {
303        let reg = Registry::default();
304        assert!(reg.needs_update_check("wallet"));
305    }
306
307    #[test]
308    fn no_check_needed_after_recent_record() {
309        let mut reg = Registry::default();
310        reg.record_check("wallet", "v1.0.0", false, "");
311        assert!(!reg.needs_update_check("wallet"));
312    }
313
314    #[test]
315    fn check_needed_after_stale_record() {
316        let mut reg = Registry::default();
317        reg.extensions.insert(
318            "wallet".to_string(),
319            ExtensionState {
320                checked_at: now_secs() - UPDATE_CHECK_INTERVAL_SECS - 1,
321                installed_version: "v1.0.0".to_string(),
322                pinned: false,
323                description: String::new(),
324            },
325        );
326        assert!(reg.needs_update_check("wallet"));
327    }
328
329    #[test]
330    fn touch_preserves_version() {
331        let mut reg = Registry::default();
332        reg.record_check("wallet", "v1.0.0", false, "");
333        reg.extensions.get_mut("wallet").unwrap().checked_at = 0;
334        reg.touch_check("wallet");
335        assert_eq!(reg.extensions["wallet"].installed_version, "v1.0.0");
336        assert!(!reg.needs_update_check("wallet"));
337    }
338
339    #[test]
340    fn touch_creates_record_if_missing() {
341        let mut reg = Registry::default();
342        reg.touch_check("wallet");
343        assert!(!reg.needs_update_check("wallet"));
344        assert_eq!(reg.extensions["wallet"].installed_version, "");
345    }
346
347    #[test]
348    fn roundtrip_serialize() {
349        let mut reg = Registry::default();
350        reg.record_check("wallet", "v1.0.0", false, "");
351        let json = serde_json::to_string(&reg).unwrap();
352        let loaded: Registry = serde_json::from_str(&json).unwrap();
353        assert_eq!(loaded.extensions["wallet"].installed_version, "v1.0.0");
354    }
355
356    #[test]
357    fn pinned_flag_recorded() {
358        let mut reg = Registry::default();
359        reg.record_check("wallet", "1.0.0", true, "");
360        assert!(reg.is_pinned("wallet"));
361    }
362
363    #[test]
364    fn not_pinned_by_default() {
365        let mut reg = Registry::default();
366        reg.record_check("wallet", "1.0.0", false, "");
367        assert!(!reg.is_pinned("wallet"));
368    }
369
370    #[test]
371    fn is_pinned_returns_false_for_unknown() {
372        let reg = Registry::default();
373        assert!(!reg.is_pinned("unknown"));
374    }
375
376    #[test]
377    fn update_unpins() {
378        let mut reg = Registry::default();
379        reg.record_check("wallet", "1.0.0", true, "");
380        assert!(reg.is_pinned("wallet"));
381        reg.record_check("wallet", "2.0.0", false, "");
382        assert!(!reg.is_pinned("wallet"));
383    }
384
385    #[test]
386    fn roundtrip_serialize_pinned() {
387        let mut reg = Registry::default();
388        reg.record_check("wallet", "1.0.0", true, "");
389        let json = serde_json::to_string(&reg).unwrap();
390        let loaded: Registry = serde_json::from_str(&json).unwrap();
391        assert!(loaded.is_pinned("wallet"));
392    }
393
394    #[test]
395    fn description_recorded() {
396        let mut reg = Registry::default();
397        reg.record_check("wallet", "1.0.0", false, "Tempo wallet");
398        assert_eq!(reg.extensions["wallet"].description, "Tempo wallet");
399    }
400
401    #[test]
402    fn deserialize_without_description_defaults_empty() {
403        let json = r#"{"extensions":{"wallet":{"checked_at":0,"installed_version":"1.0.0","pinned":false}}}"#;
404        let reg: Registry = serde_json::from_str(json).unwrap();
405        assert_eq!(reg.extensions["wallet"].description, "");
406    }
407
408    #[test]
409    fn deserialize_without_pinned_defaults_false() {
410        let json = r#"{"extensions":{"wallet":{"checked_at":0,"installed_version":"1.0.0"}}}"#;
411        let reg: Registry = serde_json::from_str(json).unwrap();
412        assert!(!reg.is_pinned("wallet"));
413    }
414}