1use serde_json::{json, Value};
55use std::path::PathBuf;
56
57pub fn state_path() -> Option<PathBuf> {
68 if let Some(explicit) = std::env::var_os("UNV_POOL_FILE") {
69 return Some(PathBuf::from(explicit));
70 }
71 #[cfg(windows)]
72 {
73 if let Some(dir) = dirs::data_local_dir() {
77 return Some(dir.join("unv").join("pools.json"));
78 }
79 }
80 if let Some(dir) = std::env::var_os("XDG_STATE_HOME") {
81 return Some(PathBuf::from(dir).join("unv").join("pools.json"));
82 }
83 dirs::home_dir().map(|h| {
84 h.join(".local")
85 .join("state")
86 .join("unv")
87 .join("pools.json")
88 })
89}
90
91fn empty() -> Value {
93 json!({ "version": 1, "vaults": {} })
94}
95
96pub fn load() -> Value {
103 state_path()
104 .and_then(|p| std::fs::read_to_string(p).ok())
105 .and_then(|raw| serde_json::from_str(&raw).ok())
106 .unwrap_or_else(empty)
107}
108
109pub fn save(state: &Value) -> Result<(), String> {
111 let path = state_path().ok_or_else(|| {
112 "Cannot determine a home directory for per-user state (no $HOME on Unix, \
113 no %USERPROFILE% on Windows). Set UNV_POOL_FILE to choose the location."
114 .to_string()
115 })?;
116 if let Some(parent) = path.parent() {
117 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
118 }
119 std::fs::write(
120 &path,
121 serde_json::to_string_pretty(state).unwrap_or_default(),
122 )
123 .map_err(|e| format!("Cannot write {}: {e}", path.display()))?;
124 restrict(&path)
125}
126
127#[cfg_attr(not(unix), allow(unused_variables))]
137fn restrict(path: &std::path::Path) -> Result<(), String> {
138 #[cfg(unix)]
139 {
140 use std::os::unix::fs::PermissionsExt;
141 let mut perms = std::fs::metadata(path)
142 .map_err(|e| e.to_string())?
143 .permissions();
144 perms.set_mode(0o600);
145 std::fs::set_permissions(path, perms).map_err(|e| e.to_string())?;
146 }
147 Ok(())
148}
149
150pub fn local_vault_key(db_path: &std::path::Path) -> String {
154 format!("local:{}", db_path.display())
155}
156
157pub fn remote_vault_key(base_url: &str) -> String {
159 format!("remote:{base_url}")
160}
161
162#[derive(Debug, Clone, Default, PartialEq, Eq)]
164pub struct MemberState {
165 pub uses: u64,
166 pub last_used_at: Option<String>,
167 pub cooling_until: Option<String>,
168}
169
170pub fn member_state(state: &Value, vault_key: &str, pool: &str, ck: &str) -> MemberState {
172 let slot = &state["vaults"][vault_key][pool]["members"][ck];
173 MemberState {
174 uses: slot["uses"].as_u64().unwrap_or(0),
175 last_used_at: slot["last_used_at"].as_str().map(str::to_string),
176 cooling_until: slot["cooling_until"].as_str().map(str::to_string),
177 }
178}
179
180pub fn cursor(state: &Value, vault_key: &str, pool: &str) -> usize {
182 state["vaults"][vault_key][pool]["cursor"]
183 .as_u64()
184 .unwrap_or(0) as usize
185}
186
187fn ensure(state: &mut Value, vault_key: &str, pool: &str) {
189 if !state["vaults"][vault_key][pool].is_object() {
190 state["vaults"][vault_key][pool] = json!({ "cursor": 0, "members": {} });
191 }
192}
193
194pub fn record_use(
196 state: &mut Value,
197 vault_key: &str,
198 pool: &str,
199 ck: &str,
200 next_cursor: usize,
201 now: i64,
202) {
203 ensure(state, vault_key, pool);
204 let uses = member_state(state, vault_key, pool, ck).uses;
205 state["vaults"][vault_key][pool]["cursor"] = json!(next_cursor as u64);
206 state["vaults"][vault_key][pool]["members"][ck]["uses"] = json!(uses + 1);
207 state["vaults"][vault_key][pool]["members"][ck]["last_used_at"] = json!(iso_at(now));
208}
209
210pub fn set_cooldown(
212 state: &mut Value,
213 vault_key: &str,
214 pool: &str,
215 ck: &str,
216 until_ts: Option<i64>,
217) {
218 ensure(state, vault_key, pool);
219 let slot = &mut state["vaults"][vault_key][pool]["members"][ck];
220 match until_ts {
221 Some(ts) => slot["cooling_until"] = json!(iso_at(ts)),
222 None => {
223 if let Some(obj) = slot.as_object_mut() {
224 obj.remove("cooling_until");
225 }
226 }
227 }
228}
229
230pub fn pick_index(cooling: &[bool], cursor: usize) -> Option<usize> {
240 let n = cooling.len();
241 if n == 0 {
242 return None;
243 }
244 (0..n).map(|off| (cursor + off) % n).find(|i| !cooling[*i])
245}
246
247pub fn forget(state: &mut Value, vault_key: &str, pool: &str) {
249 if let Some(obj) = state["vaults"][vault_key].as_object_mut() {
250 obj.remove(pool);
251 }
252}
253
254pub fn is_cooling(cooling_until: Option<&str>, now: i64) -> bool {
261 cooling_until
262 .and_then(parse_rfc3339)
263 .is_some_and(|t| t > now)
264}
265
266pub fn now_ts() -> i64 {
268 time::OffsetDateTime::now_utc().unix_timestamp()
269}
270
271pub fn iso_at(ts: i64) -> String {
278 let Ok(t) = time::OffsetDateTime::from_unix_timestamp(ts) else {
279 return String::new();
280 };
281 format!(
282 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
283 t.year(),
284 t.month() as u8,
285 t.day(),
286 t.hour(),
287 t.minute(),
288 t.second()
289 )
290}
291
292pub fn parse_rfc3339(s: &str) -> Option<i64> {
302 let b = s.as_bytes();
303 if b.len() != 20
304 || b[4] != b'-'
305 || b[7] != b'-'
306 || b[10] != b'T'
307 || b[13] != b':'
308 || b[16] != b':'
309 || b[19] != b'Z'
310 {
311 return None;
312 }
313 let num = |from: usize, to: usize| s.get(from..to)?.parse::<u32>().ok();
314 let year = num(0, 4)? as i32;
315 let month = time::Month::try_from(num(5, 7)? as u8).ok()?;
316 let day = num(8, 10)? as u8;
317 let (h, m, sec) = (num(11, 13)? as u8, num(14, 16)? as u8, num(17, 19)? as u8);
318
319 let date = time::Date::from_calendar_date(year, month, day).ok()?;
320 let time_of_day = time::Time::from_hms(h, m, sec).ok()?;
321 Some(
322 time::PrimitiveDateTime::new(date, time_of_day)
323 .assume_utc()
324 .unix_timestamp(),
325 )
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 const NOW: i64 = 1_700_000_000;
333 const A: &str = "id-aaa";
334 const B: &str = "id-bbb";
335
336 #[test]
337 fn cooling_reads_a_future_timestamp() {
338 assert!(is_cooling(Some(&iso_at(NOW + 60)), NOW));
339 }
340
341 #[test]
342 fn an_expired_cooldown_is_not_a_cooldown() {
343 assert!(!is_cooling(Some(&iso_at(NOW - 1)), NOW));
344 }
345
346 #[test]
347 fn garbage_is_not_a_cooldown() {
348 assert!(!is_cooling(Some("not a date"), NOW));
351 assert!(!is_cooling(Some(""), NOW));
352 assert!(!is_cooling(None, NOW));
353 }
354
355 #[test]
356 fn iso_at_round_trips_and_matches_iso_now_shape() {
357 let s = iso_at(NOW);
358 assert_eq!(s.len(), 20, "YYYY-MM-DDTHH:MM:SSZ");
359 assert!(s.ends_with('Z'));
360 assert_eq!(parse_rfc3339(&s), Some(NOW));
361 }
362
363 #[test]
364 fn use_and_cooldown_accumulate_on_the_right_member() {
365 let mut st = empty();
366 let vk = "local:/tmp/v.db";
367 record_use(&mut st, vk, "p", A, 1, NOW);
368 record_use(&mut st, vk, "p", A, 0, NOW);
369 record_use(&mut st, vk, "p", B, 1, NOW);
370
371 assert_eq!(member_state(&st, vk, "p", A).uses, 2);
372 assert_eq!(member_state(&st, vk, "p", B).uses, 1);
373 assert_eq!(cursor(&st, vk, "p"), 1);
374
375 set_cooldown(&mut st, vk, "p", A, Some(NOW + 900));
376 assert!(is_cooling(
377 member_state(&st, vk, "p", A).cooling_until.as_deref(),
378 NOW
379 ));
380 assert!(
381 !is_cooling(member_state(&st, vk, "p", B).cooling_until.as_deref(), NOW),
382 "cooling one member must not cool another"
383 );
384
385 set_cooldown(&mut st, vk, "p", A, None);
386 assert_eq!(
387 member_state(&st, vk, "p", A).cooling_until,
388 None,
389 "clearing removes the key rather than writing a past timestamp"
390 );
391 assert_eq!(
392 member_state(&st, vk, "p", A).uses,
393 2,
394 "clearing a cooldown must not reset the use count"
395 );
396 }
397
398 #[test]
399 fn state_is_partitioned_by_vault() {
400 let mut st = empty();
403 record_use(&mut st, "local:/a.db", "p", A, 3, NOW);
404 assert_eq!(cursor(&st, "local:/a.db", "p"), 3);
405 assert_eq!(cursor(&st, "remote:https://host", "p"), 0);
406 assert_eq!(member_state(&st, "remote:https://host", "p", A).uses, 0);
407 }
408
409 #[test]
410 fn forget_clears_one_pool_and_leaves_the_others() {
411 let mut st = empty();
412 let vk = "local:/a.db";
413 record_use(&mut st, vk, "keep", A, 1, NOW);
414 record_use(&mut st, vk, "drop", B, 1, NOW);
415 forget(&mut st, vk, "drop");
416 assert_eq!(member_state(&st, vk, "drop", B).uses, 0);
417 assert_eq!(member_state(&st, vk, "keep", A).uses, 1);
418 }
419}