use std::{
collections::HashMap,
time::{Duration, Instant},
};
use crate::commands::volume::sftp::VolumeFileEntry;
use super::app::{join_remote_path, normalize_remote_dir};
pub const TTL: Duration = Duration::from_secs(30);
pub const MAX_ENTRIES: usize = 64;
#[derive(Debug, Clone)]
pub struct CachedDir {
pub entries: Vec<VolumeFileEntry>,
pub fetched_at: Instant,
pub last_used: Instant,
}
impl CachedDir {
fn new(entries: Vec<VolumeFileEntry>, now: Instant) -> Self {
Self {
entries,
fetched_at: now,
last_used: now,
}
}
pub fn is_fresh(&self, now: Instant) -> bool {
now.saturating_duration_since(self.fetched_at) < TTL
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Lookup {
Miss,
Fresh,
Stale,
}
#[derive(Debug, Default)]
pub struct DirCache {
map: HashMap<String, CachedDir>,
}
impl DirCache {
pub fn new() -> Self {
Self::default()
}
pub fn get(&mut self, dir: &str) -> (Option<&[VolumeFileEntry]>, Lookup) {
let key = normalize_remote_dir(dir);
let now = Instant::now();
match self.map.get_mut(&key) {
Some(cached) => {
let lookup = if cached.is_fresh(now) {
Lookup::Fresh
} else {
Lookup::Stale
};
cached.last_used = now;
(Some(cached.entries.as_slice()), lookup)
}
None => (None, Lookup::Miss),
}
}
pub fn insert(&mut self, dir: &str, entries: Vec<VolumeFileEntry>) {
let key = normalize_remote_dir(dir);
let now = Instant::now();
self.map.insert(key, CachedDir::new(entries, now));
self.enforce_capacity();
}
pub fn invalidate_subtree(&mut self, dir: &str) {
let key = normalize_remote_dir(dir);
self.map
.retain(|cached_key, _| !(cached_key == &key || is_descendant(cached_key, &key)));
}
pub fn apply_delete(&mut self, parent: &str, name: &str) {
let key = normalize_remote_dir(parent);
if let Some(cached) = self.map.get_mut(&key) {
cached.entries.retain(|entry| entry.name != name);
cached.last_used = Instant::now();
}
}
pub fn apply_upsert(&mut self, parent: &str, new_entry: VolumeFileEntry) {
let key = normalize_remote_dir(parent);
if let Some(cached) = self.map.get_mut(&key) {
if let Some(existing) = cached
.entries
.iter_mut()
.find(|entry| entry.name == new_entry.name)
{
*existing = new_entry;
} else {
cached.entries.push(new_entry);
cached
.entries
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
}
cached.last_used = Instant::now();
}
}
pub fn missing_children(
&self,
parent: &str,
entries: &[VolumeFileEntry],
cap: usize,
) -> Vec<String> {
if cap == 0 {
return Vec::new();
}
let parent = normalize_remote_dir(parent);
entries
.iter()
.filter(|entry| entry.kind == "directory")
.map(|entry| join_remote_path(&parent, &entry.name))
.map(|path| normalize_remote_dir(&path))
.filter(|path| !self.map.contains_key(path))
.take(cap)
.collect()
}
fn enforce_capacity(&mut self) {
while self.map.len() > MAX_ENTRIES {
let lru_key = self
.map
.iter()
.min_by_key(|(_, cached)| cached.last_used)
.map(|(key, _)| key.clone());
match lru_key {
Some(key) => {
self.map.remove(&key);
}
None => break,
}
}
}
}
fn is_descendant(candidate: &str, ancestor: &str) -> bool {
if ancestor == "/" {
return candidate != "/";
}
candidate
.strip_prefix(ancestor)
.is_some_and(|suffix| suffix.starts_with('/'))
}