use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
pub const COOLDOWN_MS: i64 = 30 * 24 * 60 * 60 * 1000;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Entry {
capsule_id: String,
ts_ms: i64,
}
pub fn resurfaced_path(workspace_id: &str) -> PathBuf {
crate::workspace::unlost_workspace_dir(workspace_id).join("resurfaced.jsonl")
}
pub fn global_resurfaced_path() -> PathBuf {
crate::workspace::unlost_data_root().join("resurfaced.jsonl")
}
pub fn load(workspace_id: &str) -> HashMap<String, i64> {
let mut out: HashMap<String, i64> = HashMap::new();
for path in [resurfaced_path(workspace_id), global_resurfaced_path()] {
load_into(&path, &mut out);
}
out
}
fn load_into(path: &Path, out: &mut HashMap<String, i64>) {
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return,
};
let reader = std::io::BufReader::new(file);
for line in reader.lines().map_while(Result::ok) {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<Entry>(line) {
let cur = out.get(&entry.capsule_id).copied().unwrap_or(0);
if entry.ts_ms > cur {
out.insert(entry.capsule_id, entry.ts_ms);
}
}
}
}
pub fn record(_workspace_id: &str, capsule_id: &str, ts_ms: i64) {
let path = global_resurfaced_path();
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
tracing::debug!(error = %e, "resurfaced.jsonl: failed to create parent dir");
return;
}
let entry = Entry {
capsule_id: capsule_id.to_string(),
ts_ms,
};
let line = match serde_json::to_string(&entry) {
Ok(s) => s,
Err(e) => {
tracing::debug!(error = %e, "resurfaced.jsonl: serialize failed");
return;
}
};
let res = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut f| f.write_all(format!("{line}\n").as_bytes()));
if let Err(e) = res {
tracing::debug!(error = %e, path = %path.display(), "resurfaced.jsonl: append failed");
}
}
pub fn is_cooling(map: &HashMap<String, i64>, capsule_id: &str, now_ms: i64) -> bool {
map.get(capsule_id)
.map(|prev| now_ms - prev < COOLDOWN_MS)
.unwrap_or(false)
}
#[cfg(test)]
pub fn test_path_with_root(root: &Path, workspace_id: &str) -> PathBuf {
root.join("workspaces").join(workspace_id).join("resurfaced.jsonl")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cooling_window() {
let mut map = HashMap::new();
map.insert("cap_1".to_string(), 1_000_000_000_000);
assert!(is_cooling(&map, "cap_1", 1_000_000_000_000 + 86_400_000));
assert!(!is_cooling(
&map,
"cap_1",
1_000_000_000_000 + 31 * 86_400_000
));
assert!(!is_cooling(&map, "cap_2", 1_000_000_000_000 + 86_400_000));
}
#[test]
fn roundtrip_load_and_record() {
let tmp = tempfile::tempdir().unwrap();
let prev = std::env::var_os("XDG_DATA_HOME");
unsafe {
std::env::set_var("XDG_DATA_HOME", tmp.path());
}
let ws = "wks_test_roundtrip";
record(ws, "cap_x", 1_700_000_000_000);
record(ws, "cap_x", 1_700_000_000_500); record(ws, "cap_y", 1_700_000_001_000);
let map = load(ws);
assert_eq!(map.get("cap_x"), Some(&1_700_000_000_500));
assert_eq!(map.get("cap_y"), Some(&1_700_000_001_000));
unsafe {
match prev {
Some(v) => std::env::set_var("XDG_DATA_HOME", v),
None => std::env::remove_var("XDG_DATA_HOME"),
}
}
let _ = test_path_with_root(tmp.path(), ws);
}
}