iskra 0.6.1

A safe, modern, Rust-native data transfer tool.
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, Duration};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::io::{self, Write, Read, BufRead};
use std::fs::OpenOptions;
use std::collections::BTreeMap;
use linked_hash_map::LinkedHashMap;
use std::sync::Mutex;
use linked_hash_map::Entry;

/// Metadata for a cached segment (e.g., HTTP headers, range info)
#[derive(Debug, Clone, Default)]
pub struct CacheMeta {
    pub headers: BTreeMap<String, String>,
    pub range: Option<(u64, u64)>, // inclusive
}
/// Main cache struct: wraps disk and in-memory cache
pub struct Cache {
    dir: PathBuf,
    ttl: Duration,
    mem: Mutex<MemCache>,
}
/// Simple hand-rolled LRU cache for hot items (in-memory, thread-safe)
/// Capacity is fixed (128 items); TTL is enforced on get()
// TODO: More advanced eviction policies (e.g., LFU)
const MEM_CACHE_CAPACITY: usize = 128;
type MemKey = (String, Option<(u64, u64)>);
type MemValue = (Vec<u8>, CacheMeta, SystemTime);
struct MemCache {
    map: LinkedHashMap<MemKey, MemValue>,
    hits: usize,
    misses: usize,
    ttl: Duration,
}

impl MemCache {
    fn new() -> Self {
    Self { map: LinkedHashMap::with_capacity(MEM_CACHE_CAPACITY), hits: 0, misses: 0, ttl: Duration::from_secs(3600) }
    }
    fn get(&mut self, key: &MemKey, ttl: Duration) -> Option<(Vec<u8>, CacheMeta)> {
        // Use get (not get_refresh) to avoid LRU update on read-only access
        if let Some((data, meta, inserted)) = self.map.get(key) {
            if inserted.elapsed().unwrap_or(ttl + Duration::from_secs(1)) > ttl {
                self.map.remove(key);
                self.misses += 1;
                return None;
            }
            self.hits += 1;
            Some((data.clone(), meta.clone()))
        } else {
            self.misses += 1;
            None
        }
    }

    fn stats(&self) -> (usize, usize) {
        (self.hits, self.misses)
    }
    fn put(&mut self, key: MemKey, val: (Vec<u8>, CacheMeta)) {
        match self.map.entry(key) {
            Entry::Occupied(mut occ) => {
                let v = occ.get_mut();
                v.0 = val.0;
                v.1 = val.1;
                v.2 = SystemTime::now();
            }
            Entry::Vacant(vac) => {
                vac.insert((val.0, val.1, SystemTime::now()));
            }
        }
        if self.map.len() > MEM_CACHE_CAPACITY {
            let mut removed = false;
            while self.map.len() > MEM_CACHE_CAPACITY {
                let oldest = self.map.front().map(|(k, _)| k.clone());
                if let Some(k) = oldest {
                    self.map.remove(&k);
                    removed = true;
                }
            }
            if removed {
                self.cleanup_expired();
            }
        }
    }
    /// Remove all expired entries from the cache (based on TTL)
    fn cleanup_expired(&mut self) {
        let now = SystemTime::now();
        let ttl = self.ttl;
        let expired: Vec<_> = self.map.iter()
            .filter_map(|(k, (_, _, inserted))| {
                if now.duration_since(*inserted).unwrap_or(ttl + Duration::from_secs(1)) > ttl {
                    Some(k.clone())
                } else {
                    None
                }
            })
            .collect();
        for k in expired {
            self.map.remove(&k);
        }
    }
}



impl Cache {
    /// Get cache hit/miss stats (memory only)
    pub fn mem_stats(&self) -> (usize, usize) {
        self.mem.lock().map(|m| m.stats()).unwrap_or((0, 0))
    }
    /// Create a new cache in the given directory, with a time-to-live (ttl) in seconds.
    pub fn new(dir: PathBuf, ttl_secs: u64) -> Self {
        fs::create_dir_all(&dir).ok();
        let ttl = Duration::from_secs(ttl_secs);
        Self {
            dir,
            ttl,
            mem: Mutex::new({
                let mut m = MemCache::new();
                m.ttl = ttl;
                m
            }),
        }
    }
    /// Get a cached response for a key (e.g., URL+method+headers+query) and optional range.
    /// Returns (data, meta) if found and not expired.
    pub fn get(&self, key: &str, range: Option<(u64, u64)>) -> Option<(Vec<u8>, CacheMeta)> {
        let mem_key = (key.to_string(), range);
        // Try memory cache first, respecting TTL
        {
            let mut mem = self.mem.lock().ok()?;
            if let Some(val) = mem.get(&mem_key, self.ttl) {
                return Some(val);
            }
        }
        // Fallback to disk
        let path = self.key_path(key, range);
        let meta_path = self.meta_path(key, range);
        let meta = fs::metadata(&path).ok()?;
        let modified = meta.modified().ok()?;
        if SystemTime::now().duration_since(modified).ok()? > self.ttl {
            let _ = fs::remove_file(&path);
            let _ = fs::remove_file(&meta_path);
            return None;
        }
        let data = fs::read(&path).ok()?;
        let meta = Self::read_meta(&meta_path).unwrap_or_default();
        // Insert into memory cache for next time (avoid extra clone)
        if let Ok(mut mem) = self.mem.lock() {
            mem.put(mem_key, (data.clone(), meta.clone()));
        }
        Some((data, meta))
    }
    /// Store a response for a key and optional range, with metadata.
    pub fn set(&self, key: &str, range: Option<(u64, u64)>, value: &[u8], meta: &CacheMeta) {
        let path = self.key_path(key, range);
        let meta_path = self.meta_path(key, range);
        // Use File::create for a slight speedup
        if let Ok(mut file) = fs::File::create(&path) {
            let _ = file.write_all(value);
        }
        let _ = Self::write_meta(&meta_path, meta);
        // Also update memory cache
        let mem_key = (key.to_string(), range);
        let mem_val = (value.to_vec(), meta.clone());
        if let Ok(mut mem) = self.mem.lock() {
            mem.put(mem_key, mem_val);
        }
    }
    /// Stream a response to cache (append or write at offset for partials).
    pub fn stream_to_cache(&self, key: &str, range: Option<(u64, u64)>, mut reader: impl Read, meta: &CacheMeta) -> io::Result<()> {
        let path = self.key_path(key, range);
        let meta_path = self.meta_path(key, range);
        let mut file = OpenOptions::new().create(true).write(true).truncate(true).open(&path)?;
        io::copy(&mut reader, &mut file)?;
        Self::write_meta(&meta_path, meta)?;
        Ok(())
    }
    /// Remove a cached entry (and its metadata)
    pub fn remove(&self, key: &str, range: Option<(u64, u64)>) {
        let path = self.key_path(key, range);
        let meta_path = self.meta_path(key, range);
        let _ = fs::remove_file(&path);
        let _ = fs::remove_file(&meta_path);
    }

    pub fn key_path(&self, key: &str, range: Option<(u64, u64)>) -> PathBuf {
        let mut hasher = DefaultHasher::new();
        key.hash(&mut hasher);
        if let Some((start, end)) = range {
            (start, end).hash(&mut hasher);
        }
        let hash = hasher.finish();
        self.dir.join(format!("{}.cache", hash))
    }

    pub fn meta_path(&self, key: &str, range: Option<(u64, u64)>) -> PathBuf {
        let mut hasher = DefaultHasher::new();
        key.hash(&mut hasher);
        if let Some((start, end)) = range {
            (start, end).hash(&mut hasher);
        }
        let hash = hasher.finish();
        self.dir.join(format!("{}.meta", hash))
    }

    fn write_meta(path: &Path, meta: &CacheMeta) -> io::Result<()> {
        let mut file = OpenOptions::new().create(true).write(true).truncate(true).open(path)?;
        for (k, v) in &meta.headers {
            writeln!(file, "header:{}:{}", k, v)?;
        }
        if let Some((start, end)) = meta.range {
            writeln!(file, "range:{}-{}", start, end)?;
        }
        Ok(())
    }

    fn read_meta(path: &Path) -> io::Result<CacheMeta> {
        let mut meta = CacheMeta::default();
        let file = OpenOptions::new().read(true).open(path)?;
        for line in io::BufReader::new(file).lines() {
            let line = line?;
            if let Some(rest) = line.strip_prefix("header:") {
                if let Some((k, v)) = rest.split_once(":") {
                    meta.headers.insert(k.to_string(), v.to_string());
                }
            } else if let Some(rest) = line.strip_prefix("range:") {
                if let Some((s, e)) = rest.split_once('-') {
                    if let (Ok(start), Ok(end)) = (s.parse(), e.parse()) {
                        meta.range = Some((start, end));
                    }
                }
            }
        }
        Ok(meta)
    }
}