lmocr 0.1.0

Convert PDFs and images to Markdown using OpenRouter multimodal LLMs
use anyhow::Result;
use blake3::Hasher;
use std::fs;
use std::path::{Path, PathBuf};

use crate::prompt::{Exclude, Preset, prompt_version};

pub struct CacheKeyInput<'a> {
    pub source: &'a Path,
    pub page: usize,
    pub total_pages: usize,
    pub dpi: u32,
    pub model: &'a str,
    pub preset: Preset,
    pub excludes: &'a [Exclude],
    pub instruction: Option<&'a str>,
    pub prev_tail: Option<&'a str>,
    pub input_mode: &'a str,
}

pub fn cache_path(override_path: Option<&Path>) -> PathBuf {
    override_path
        .map(|p| p.to_path_buf())
        .unwrap_or_else(|| PathBuf::from(".lmocr/cache"))
}

pub fn load_cached(path: &Path) -> Option<String> {
    fs::read_to_string(path).ok()
}

pub fn save_cached(path: &Path, content: &str) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(path, content)?;
    Ok(())
}

pub fn cache_key(input: &CacheKeyInput<'_>) -> Result<String> {
    let metadata = fs::metadata(input.source)?;
    let modified = metadata
        .modified()
        .ok()
        .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let size = metadata.len();

    let mut hasher = Hasher::new();
    hasher.update(input.source.to_string_lossy().as_bytes());
    hasher.update(&size.to_le_bytes());
    hasher.update(&modified.to_le_bytes());
    hasher.update(&input.page.to_le_bytes());
    hasher.update(&input.total_pages.to_le_bytes());
    hasher.update(&input.dpi.to_le_bytes());
    hasher.update(input.model.as_bytes());
    hasher.update(format!("{:?}", input.preset).as_bytes());
    hasher.update(format!("{:?}", input.excludes).as_bytes());
    hasher.update(prompt_version().as_bytes());
    hasher.update(input.input_mode.as_bytes());
    if let Some(extra) = input.instruction {
        hasher.update(extra.as_bytes());
    }
    if let Some(tail) = input.prev_tail {
        hasher.update(tail.as_bytes());
    }
    Ok(hasher.finalize().to_hex().to_string())
}

#[cfg(test)]
mod tests {
    use super::{CacheKeyInput, cache_key};
    use crate::prompt::{Exclude, Preset};
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn cache_key_differs_by_input_mode() {
        let dir = TempDir::new().unwrap();
        let source = dir.path().join("scan.png");
        fs::write(&source, b"fake image").unwrap();
        let excludes: &[Exclude] = &[];

        let single = cache_key(&CacheKeyInput {
            source: &source,
            page: 1,
            total_pages: 1,
            dpi: 0,
            model: "google/gemini-3-flash-preview",
            preset: Preset::Markdown,
            excludes,
            instruction: None,
            prev_tail: None,
            input_mode: "single_image",
        })
        .unwrap();

        let folder = cache_key(&CacheKeyInput {
            source: &source,
            page: 1,
            total_pages: 1,
            dpi: 0,
            model: "google/gemini-3-flash-preview",
            preset: Preset::Markdown,
            excludes,
            instruction: None,
            prev_tail: None,
            input_mode: "image_folder_page",
        })
        .unwrap();

        assert_ne!(single, folder);
    }
}