searchfox-lib 0.13.0

Library for searchfox.org API access
Documentation
use rusqlite::{params, Connection};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

pub const FRESH_SECS: u64 = 3600;
const PRUNE_SECS: u64 = 7 * 24 * 3600;

pub struct CacheEntry {
    pub content: String,
    pub etag: Option<String>,
    pub last_modified: Option<String>,
    pub cached_at: u64,
}

impl CacheEntry {
    pub fn is_fresh(&self) -> bool {
        now().saturating_sub(self.cached_at) < FRESH_SECS
    }
}

fn now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

pub fn cache_path() -> Option<PathBuf> {
    let base = if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
        PathBuf::from(xdg)
    } else {
        PathBuf::from(std::env::var("HOME").ok()?).join(".cache")
    };
    Some(base.join("searchfox-cli").join("cache.db"))
}

pub fn open() -> Option<Connection> {
    let path = cache_path()?;
    std::fs::create_dir_all(path.parent()?).ok()?;
    let conn = Connection::open(&path).ok()?;
    init(&conn)?;
    Some(conn)
}

pub fn get(conn: &Connection, url: &str) -> Option<CacheEntry> {
    conn.query_row(
        "SELECT content, etag, last_modified, cached_at FROM cache WHERE url = ?1",
        params![url],
        |row| {
            Ok(CacheEntry {
                content: row.get(0)?,
                etag: row.get(1)?,
                last_modified: row.get(2)?,
                cached_at: row.get::<_, i64>(3)? as u64,
            })
        },
    )
    .ok()
}

pub fn set(
    conn: &Connection,
    url: &str,
    content: &str,
    etag: Option<&str>,
    last_modified: Option<&str>,
) {
    let _ = conn.execute(
        "INSERT OR REPLACE INTO cache (url, content, etag, last_modified, cached_at)
         VALUES (?1, ?2, ?3, ?4, ?5)",
        params![url, content, etag, last_modified, now() as i64],
    );
}

fn init(conn: &Connection) -> Option<()> {
    conn.execute_batch(
        "CREATE TABLE IF NOT EXISTS cache (
            url TEXT PRIMARY KEY,
            content TEXT NOT NULL,
            etag TEXT,
            last_modified TEXT,
            cached_at INTEGER NOT NULL
        );",
    )
    .ok()
}

pub fn open_in_memory() -> anyhow::Result<Connection> {
    let conn = Connection::open_in_memory()?;
    init(&conn).ok_or_else(|| anyhow::anyhow!("failed to init in-memory cache"))?;
    Ok(conn)
}

#[cfg(test)]
pub fn set_with_timestamp(
    conn: &Connection,
    url: &str,
    content: &str,
    etag: Option<&str>,
    last_modified: Option<&str>,
    cached_at: i64,
) {
    let _ = conn.execute(
        "INSERT OR REPLACE INTO cache (url, content, etag, last_modified, cached_at)
         VALUES (?1, ?2, ?3, ?4, ?5)",
        params![url, content, etag, last_modified, cached_at],
    );
}

pub fn prune(conn: &Connection) {
    let cutoff = (now() - PRUNE_SECS) as i64;
    let _ = conn.execute("DELETE FROM cache WHERE cached_at < ?1", params![cutoff]);
}

pub fn clear() -> std::io::Result<bool> {
    let Some(path) = cache_path() else {
        return Ok(false);
    };

    match std::fs::remove_file(path) {
        Ok(()) => Ok(true),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
        Err(e) => Err(e),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;
    use std::sync::{Mutex, OnceLock};

    static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();

    fn db() -> Connection {
        open_in_memory().unwrap()
    }

    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
        ENV_LOCK
            .get_or_init(|| Mutex::new(()))
            .lock()
            .expect("env lock poisoned")
    }

    fn temp_cache_home() -> PathBuf {
        std::env::temp_dir().join(format!(
            "searchfox-cli-cache-test-{}-{}",
            std::process::id(),
            now()
        ))
    }

    #[test]
    fn miss_returns_none() {
        let conn = db();
        assert!(get(&conn, "https://example.com/file").is_none());
    }

    #[test]
    fn set_get_roundtrip() {
        let conn = db();
        set(
            &conn,
            "https://example.com/file",
            "hello",
            Some("\"abc\""),
            Some("Thu, 01 Jan 2026 00:00:00 GMT"),
        );
        let entry = get(&conn, "https://example.com/file").unwrap();
        assert_eq!(entry.content, "hello");
        assert_eq!(entry.etag.as_deref(), Some("\"abc\""));
        assert_eq!(
            entry.last_modified.as_deref(),
            Some("Thu, 01 Jan 2026 00:00:00 GMT")
        );
    }

    #[test]
    fn set_without_etag() {
        let conn = db();
        set(&conn, "https://example.com/file", "content", None, None);
        let entry = get(&conn, "https://example.com/file").unwrap();
        assert_eq!(entry.content, "content");
        assert!(entry.etag.is_none());
        assert!(entry.last_modified.is_none());
    }

    #[test]
    fn fresh_entry_is_fresh() {
        let conn = db();
        set(&conn, "https://example.com/file", "x", None, None);
        assert!(get(&conn, "https://example.com/file").unwrap().is_fresh());
    }

    #[test]
    fn old_entry_is_not_fresh() {
        let conn = db();
        set_with_timestamp(&conn, "https://example.com/file", "x", None, None, 0);
        assert!(!get(&conn, "https://example.com/file").unwrap().is_fresh());
    }

    #[test]
    fn set_replaces_existing_entry() {
        let conn = db();
        set(
            &conn,
            "https://example.com/file",
            "v1",
            Some("\"etag1\""),
            None,
        );
        set(
            &conn,
            "https://example.com/file",
            "v2",
            Some("\"etag2\""),
            None,
        );
        let entry = get(&conn, "https://example.com/file").unwrap();
        assert_eq!(entry.content, "v2");
        assert_eq!(entry.etag.as_deref(), Some("\"etag2\""));
    }

    #[test]
    fn prune_removes_old_keeps_fresh() {
        let conn = db();
        set_with_timestamp(&conn, "https://example.com/old", "old", None, None, 0);
        set(&conn, "https://example.com/new", "new", None, None);
        prune(&conn);
        assert!(get(&conn, "https://example.com/old").is_none());
        assert!(get(&conn, "https://example.com/new").is_some());
    }

    #[test]
    fn different_urls_are_independent() {
        let conn = db();
        set(&conn, "https://example.com/a", "aaa", Some("\"e1\""), None);
        set(&conn, "https://example.com/b", "bbb", Some("\"e2\""), None);
        assert_eq!(get(&conn, "https://example.com/a").unwrap().content, "aaa");
        assert_eq!(get(&conn, "https://example.com/b").unwrap().content, "bbb");
    }

    #[test]
    fn clear_removes_database_file() {
        let _guard = env_lock();
        let dir = temp_cache_home();
        let previous = std::env::var_os("XDG_CACHE_HOME");
        std::env::set_var("XDG_CACHE_HOME", &dir);

        {
            let path = cache_path().expect("cache path");
            std::fs::create_dir_all(path.parent().expect("cache dir")).unwrap();
            std::fs::write(&path, b"cache").unwrap();

            assert!(path.exists());
            assert!(clear().unwrap());
            assert!(!path.exists());
            assert!(!clear().unwrap());
        }

        if let Some(value) = previous {
            std::env::set_var("XDG_CACHE_HOME", value);
        } else {
            std::env::remove_var("XDG_CACHE_HOME");
        }
        let _ = std::fs::remove_dir_all(&dir);
    }
}