use std::collections::{BTreeSet, HashMap};
use std::path::Path;
use std::time::{Duration, SystemTime};
use anyhow::Result;
pub(super) const KEPT_FOR: Duration = Duration::from_secs(30 * 24 * 60 * 60);
pub(super) const MERGE_AFTER: usize = 16;
#[derive(Default)]
pub(super) struct Known {
pub listings: BTreeSet<String>,
pub keys: HashMap<String, u64>,
}
impl Known {
pub fn add(&mut self, listing: &str) {
for (key, time) in parse(listing) {
let known = self.keys.entry(key).or_insert(time);
*known = (*known).max(time);
}
}
pub fn expire(&mut self, now: u64) {
let oldest = now.saturating_sub(KEPT_FOR.as_millis() as u64);
self.keys.retain(|_, time| *time >= oldest);
}
pub fn load(path: &Path) -> Self {
let mut known = Self::default();
let text = std::fs::File::open(path)
.ok()
.and_then(|file| super::read_bounded(file, super::LARGEST_INDEX, None).ok())
.and_then(|bytes| String::from_utf8(bytes).ok());
let Some(text) = text else {
return known;
};
for line in text.lines() {
if let Some(name) = line.strip_prefix("listing ") {
known.listings.insert(name.to_owned());
}
}
known.add(&text);
known
}
pub fn save(&self, path: &Path) -> Result<()> {
let directory = path.parent().unwrap_or(Path::new("."));
std::fs::create_dir_all(directory)?;
let mut text = String::new();
for name in &self.listings {
text.push_str(&format!("listing {name}\n"));
}
text.push_str(&format(
self.keys.iter().map(|(key, time)| (key.as_str(), *time)),
));
let mut file = tempfile::NamedTempFile::new_in(directory)?;
std::io::Write::write_all(&mut file, text.as_bytes())?;
file.persist(path)?;
Ok(())
}
}
pub(super) fn parse(listing: &str) -> impl Iterator<Item = (String, u64)> + '_ {
listing.lines().filter_map(|line| {
let (key, time) = line.split_once(' ')?;
let time = time.parse().ok()?;
crate::store::valid_hash(key).then(|| (key.to_owned(), time))
})
}
pub(super) fn format<'a>(keys: impl Iterator<Item = (&'a str, u64)>) -> String {
let mut lines: Vec<String> = keys.map(|(key, time)| format!("{key} {time}\n")).collect();
lines.sort();
lines.concat()
}
pub(super) fn name(now: u64) -> String {
static NAMED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let unique = blake3::hash(
format!(
"{}\0{:?}\0{}",
std::process::id(),
SystemTime::now(),
NAMED.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
)
.as_bytes(),
);
format!("{now:013}-{}", &unique.to_hex()[..16])
}
pub(super) fn now() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |since| since.as_millis() as u64)
}
#[cfg(test)]
mod tests {
use super::*;
fn key(digit: char) -> String {
digit.to_string().repeat(64)
}
#[test]
fn listings_merge_keeping_the_latest_time_and_skip_malformed_lines() {
let mut known = Known::default();
known.add(&format(
[(key('a').as_str(), 5), (key('b').as_str(), 7)].into_iter(),
));
known.add(&format!(
"{} 9\nnot a line\n{} soon\nshort 3\n",
key('a'),
key('c')
));
assert_eq!(known.keys.len(), 2);
assert_eq!(known.keys[&key('a')], 9);
assert_eq!(known.keys[&key('b')], 7);
}
#[test]
fn keys_not_found_lately_expire() {
let mut known = Known::default();
let now = KEPT_FOR.as_millis() as u64 * 2;
known.add(&format(
[(key('a').as_str(), now - 1), (key('b').as_str(), 1)].into_iter(),
));
known.expire(now);
assert_eq!(known.keys.keys().collect::<Vec<_>>(), [&key('a')]);
}
#[test]
fn the_local_copy_round_trips() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("remote/store");
let mut known = Known::default();
known.listings.insert("0000000000001-abc".into());
known.add(&format([(key('a').as_str(), 3)].into_iter()));
known.save(&path).unwrap();
let loaded = Known::load(&path);
assert_eq!(loaded.listings, known.listings);
assert_eq!(loaded.keys, known.keys);
assert!(
Known::load(&directory.path().join("missing"))
.keys
.is_empty()
);
}
#[test]
fn names_sort_by_time() {
assert!(name(999) < name(1000));
assert_ne!(name(5), name(5));
}
}