cueward-adapter-macos 0.5.0

macOS adapter for Cueward with Safari, Notes, Messages, Reminders, Calendar, Screenshot, Clipboard, and OCR integrations.
use std::fs;
use std::path::Path;
use std::path::PathBuf;

use crate::MacosError;
use crate::ocr;

use super::super::{OCR_EMPTY_SENTINEL, home_dir};

pub(super) fn load_or_run_file_ocr(
    path: &Path,
    sha256: Option<&str>,
    filename: &str,
) -> Result<Option<String>, MacosError> {
    let cache_path = if let Some(hash) = sha256 {
        ocr_cache_file_path(hash)?
    } else {
        let sanitized = filename.replace('/', "_");
        ocr_cache_dir()?.join(format!("name-{sanitized}.txt"))
    };

    if let Ok(cached) = fs::read_to_string(&cache_path) {
        if let Some(cached_text) = cached_ocr_text(&cached) {
            if cached_text.is_empty() {
                return Ok(None);
            }
            return Ok(Some(cached_text));
        }
    }

    let cues = ocr::capture(&path.to_string_lossy())?;
    let text = cues
        .iter()
        .map(|cue| cue.content.trim())
        .filter(|text| !text.is_empty())
        .collect::<Vec<_>>()
        .join("\n");

    fs::create_dir_all(ocr_cache_dir()?)
        .map_err(|err| MacosError::Other(format!("failed to create OCR cache dir: {err}")))?;

    let cached_value = if text.is_empty() {
        OCR_EMPTY_SENTINEL.to_string()
    } else {
        text.clone()
    };

    fs::write(&cache_path, cached_value)
        .map_err(|err| MacosError::Other(format!("failed to write OCR cache: {err}")))?;

    if text.is_empty() {
        Ok(None)
    } else {
        Ok(Some(text))
    }
}

pub(super) fn cached_ocr_text(cached: &str) -> Option<String> {
    if cached.is_empty() {
        return None;
    }
    if cached.trim() == OCR_EMPTY_SENTINEL {
        return Some(String::new());
    }
    Some(cached.to_string())
}

fn ocr_cache_dir() -> Result<PathBuf, MacosError> {
    Ok(home_dir()?.join(".cueward/cache/ocr"))
}

pub(super) fn ocr_cache_file_path(hash: &str) -> Result<PathBuf, MacosError> {
    Ok(ocr_cache_dir()?.join(format!("{hash}.txt")))
}

#[cfg(test)]
mod tests {
    use super::{cached_ocr_text, ocr_cache_file_path};
    use crate::notes::OCR_EMPTY_SENTINEL;

    #[test]
    fn ocr_cache_file_path_uses_hash_name() {
        let path = ocr_cache_file_path("abc123").expect("cache path");

        assert!(path.ends_with(".cueward/cache/ocr/abc123.txt"));
    }

    #[test]
    fn cached_ocr_text_treats_empty_files_as_cache_miss() {
        assert_eq!(cached_ocr_text(""), None);
        assert_eq!(cached_ocr_text(OCR_EMPTY_SENTINEL), Some(String::new()));
    }
}