1use 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; #[derive(Debug, Default, Serialize, Deserialize)]
20pub(crate) struct Registry {
21 #[serde(default)]
23 pub(crate) extensions: HashMap<String, ExtensionState>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub(crate) struct ExtensionState {
29 pub(crate) checked_at: u64,
31 pub(crate) installed_version: String,
33 #[serde(default)]
37 pub(crate) pinned: bool,
38 #[serde(default)]
40 pub(crate) description: String,
41}
42
43impl Registry {
44 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 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 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 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 pub(crate) fn is_pinned(&self, extension: &str) -> bool {
130 self.extensions.get(extension).is_some_and(|e| e.pinned)
131 }
132
133 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 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
161fn 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 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 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(®).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(®).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}