iskra 0.2.2

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;


/// 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
}

pub struct Cache {
    dir: PathBuf,
    ttl: Duration,
}

impl Cache {
    /// 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();
        Self { dir, ttl: Duration::from_secs(ttl_secs) }
    }


    /// 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 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();
        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);
        let _ = fs::write(&path, value);
        let _ = Self::write_meta(&meta_path, meta);
    }

    /// 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)
    }
}