use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
pub const CACHE_EXTENSION: &str = ".superbook-cache";
pub const CACHE_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheDigest {
pub source_modified: u64,
pub source_size: u64,
pub options_hash: String,
}
impl CacheDigest {
pub fn new<P: AsRef<Path>>(source_path: P, options_json: &str) -> io::Result<Self> {
let metadata = fs::metadata(source_path.as_ref())?;
let modified = metadata
.modified()
.unwrap_or(SystemTime::UNIX_EPOCH)
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let size = metadata.len();
let mut hasher = Sha256::new();
hasher.update(options_json.as_bytes());
let hash = format!("sha256:{:x}", hasher.finalize());
Ok(Self {
source_modified: modified,
source_size: size,
options_hash: hash,
})
}
pub fn with_values(source_modified: u64, source_size: u64, options_hash: &str) -> Self {
Self {
source_modified,
source_size,
options_hash: options_hash.to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessingResult {
pub page_count: usize,
pub page_number_shift: Option<i32>,
pub is_vertical: bool,
pub elapsed_seconds: f64,
pub output_size: u64,
}
impl Default for ProcessingResult {
fn default() -> Self {
Self {
page_count: 0,
page_number_shift: None,
is_vertical: false,
elapsed_seconds: 0.0,
output_size: 0,
}
}
}
impl ProcessingResult {
pub fn new(
page_count: usize,
page_number_shift: Option<i32>,
is_vertical: bool,
elapsed_seconds: f64,
output_size: u64,
) -> Self {
Self {
page_count,
page_number_shift,
is_vertical,
elapsed_seconds,
output_size,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessingCache {
pub version: u32,
pub processed_at: u64,
pub digest: CacheDigest,
pub result: ProcessingResult,
}
impl ProcessingCache {
pub fn new(digest: CacheDigest, result: ProcessingResult) -> Self {
let processed_at = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
version: CACHE_VERSION,
processed_at,
digest,
result,
}
}
pub fn cache_path<P: AsRef<Path>>(output_path: P) -> PathBuf {
let mut path = output_path.as_ref().as_os_str().to_owned();
path.push(CACHE_EXTENSION);
PathBuf::from(path)
}
pub fn load<P: AsRef<Path>>(output_path: P) -> io::Result<Self> {
let cache_path = Self::cache_path(output_path);
let content = fs::read_to_string(&cache_path)?;
serde_json::from_str(&content)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
pub fn save<P: AsRef<Path>>(&self, output_path: P) -> io::Result<()> {
let cache_path = Self::cache_path(output_path);
let content = serde_json::to_string_pretty(self)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(&cache_path, content)
}
pub fn is_valid(&self, digest: &CacheDigest) -> bool {
self.version == CACHE_VERSION && self.digest == *digest
}
pub fn delete<P: AsRef<Path>>(output_path: P) -> io::Result<()> {
let cache_path = Self::cache_path(output_path);
if cache_path.exists() {
fs::remove_file(cache_path)?;
}
Ok(())
}
}
pub fn should_skip_processing<P1: AsRef<Path>, P2: AsRef<Path>>(
source_path: P1,
output_path: P2,
options_json: &str,
force: bool,
) -> Option<ProcessingCache> {
if force {
return None;
}
if !output_path.as_ref().exists() {
return None;
}
let digest = CacheDigest::new(&source_path, options_json).ok()?;
let cache = ProcessingCache::load(&output_path).ok()?;
if cache.is_valid(&digest) {
Some(cache)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_cache_digest_new() {
let mut temp = NamedTempFile::new().unwrap();
temp.write_all(b"test content").unwrap();
let digest = CacheDigest::new(temp.path(), r#"{"dpi": 300}"#).unwrap();
assert!(digest.source_modified > 0);
assert_eq!(digest.source_size, 12); assert!(digest.options_hash.starts_with("sha256:"));
}
#[test]
fn test_cache_digest_with_values() {
let digest = CacheDigest::with_values(1234567890, 999, "sha256:abc123");
assert_eq!(digest.source_modified, 1234567890);
assert_eq!(digest.source_size, 999);
assert_eq!(digest.options_hash, "sha256:abc123");
}
#[test]
fn test_cache_digest_same_options_same_hash() {
let mut temp = NamedTempFile::new().unwrap();
temp.write_all(b"test").unwrap();
let digest1 = CacheDigest::new(temp.path(), r#"{"dpi": 300}"#).unwrap();
let digest2 = CacheDigest::new(temp.path(), r#"{"dpi": 300}"#).unwrap();
assert_eq!(digest1, digest2);
}
#[test]
fn test_cache_digest_different_file() {
let mut temp1 = NamedTempFile::new().unwrap();
temp1.write_all(b"content1").unwrap();
let mut temp2 = NamedTempFile::new().unwrap();
temp2.write_all(b"different content").unwrap();
let digest1 = CacheDigest::new(temp1.path(), r#"{"dpi": 300}"#).unwrap();
let digest2 = CacheDigest::new(temp2.path(), r#"{"dpi": 300}"#).unwrap();
assert_ne!(digest1.source_size, digest2.source_size);
}
#[test]
fn test_cache_digest_different_options() {
let mut temp = NamedTempFile::new().unwrap();
temp.write_all(b"test").unwrap();
let digest1 = CacheDigest::new(temp.path(), r#"{"dpi": 300}"#).unwrap();
let digest2 = CacheDigest::new(temp.path(), r#"{"dpi": 600}"#).unwrap();
assert_ne!(digest1.options_hash, digest2.options_hash);
}
#[test]
fn test_cache_digest_nonexistent_file() {
let result = CacheDigest::new("/nonexistent/file.pdf", "{}");
assert!(result.is_err());
}
#[test]
fn test_processing_result_default() {
let result = ProcessingResult::default();
assert_eq!(result.page_count, 0);
assert_eq!(result.page_number_shift, None);
assert!(!result.is_vertical);
assert_eq!(result.elapsed_seconds, 0.0);
assert_eq!(result.output_size, 0);
}
#[test]
fn test_processing_result_new() {
let result = ProcessingResult::new(100, Some(2), true, 45.5, 12345678);
assert_eq!(result.page_count, 100);
assert_eq!(result.page_number_shift, Some(2));
assert!(result.is_vertical);
assert_eq!(result.elapsed_seconds, 45.5);
assert_eq!(result.output_size, 12345678);
}
#[test]
fn test_processing_cache_new() {
let digest = CacheDigest::with_values(1234567890, 999, "sha256:abc");
let result = ProcessingResult::default();
let cache = ProcessingCache::new(digest.clone(), result);
assert_eq!(cache.version, CACHE_VERSION);
assert!(cache.processed_at > 0);
assert_eq!(cache.digest, digest);
}
#[test]
fn test_processing_cache_path() {
let path = ProcessingCache::cache_path("/output/file.pdf");
assert_eq!(path.to_string_lossy(), "/output/file.pdf.superbook-cache");
}
#[test]
fn test_processing_cache_save_load() {
let temp_dir = tempfile::tempdir().unwrap();
let output_path = temp_dir.path().join("output.pdf");
let digest = CacheDigest::with_values(1234567890, 999, "sha256:abc");
let result = ProcessingResult::new(50, Some(3), true, 10.5, 5000000);
let cache = ProcessingCache::new(digest.clone(), result);
cache.save(&output_path).unwrap();
let loaded = ProcessingCache::load(&output_path).unwrap();
assert_eq!(loaded.version, cache.version);
assert_eq!(loaded.digest, cache.digest);
assert_eq!(loaded.result.page_count, 50);
assert_eq!(loaded.result.page_number_shift, Some(3));
assert!(loaded.result.is_vertical);
}
#[test]
fn test_processing_cache_load_nonexistent() {
let result = ProcessingCache::load("/nonexistent/file.pdf");
assert!(result.is_err());
}
#[test]
fn test_processing_cache_is_valid_same() {
let digest = CacheDigest::with_values(1234567890, 999, "sha256:abc");
let cache = ProcessingCache::new(digest.clone(), ProcessingResult::default());
assert!(cache.is_valid(&digest));
}
#[test]
fn test_processing_cache_is_valid_different_digest() {
let digest1 = CacheDigest::with_values(1234567890, 999, "sha256:abc");
let digest2 = CacheDigest::with_values(1234567890, 1000, "sha256:abc");
let cache = ProcessingCache::new(digest1, ProcessingResult::default());
assert!(!cache.is_valid(&digest2));
}
#[test]
fn test_processing_cache_version_mismatch() {
let temp_dir = tempfile::tempdir().unwrap();
let output_path = temp_dir.path().join("output.pdf");
let cache_content = r#"{
"version": 999,
"processed_at": 1234567890,
"digest": {
"source_modified": 1234567890,
"source_size": 999,
"options_hash": "sha256:abc"
},
"result": {
"page_count": 10,
"page_number_shift": null,
"is_vertical": false,
"elapsed_seconds": 5.0,
"output_size": 1000
}
}"#;
let cache_path = ProcessingCache::cache_path(&output_path);
fs::write(&cache_path, cache_content).unwrap();
let loaded = ProcessingCache::load(&output_path).unwrap();
let digest = CacheDigest::with_values(1234567890, 999, "sha256:abc");
assert!(!loaded.is_valid(&digest));
}
#[test]
fn test_processing_cache_corrupted() {
let temp_dir = tempfile::tempdir().unwrap();
let output_path = temp_dir.path().join("output.pdf");
let cache_path = ProcessingCache::cache_path(&output_path);
fs::write(&cache_path, "not valid json").unwrap();
let result = ProcessingCache::load(&output_path);
assert!(result.is_err());
}
#[test]
fn test_processing_cache_delete() {
let temp_dir = tempfile::tempdir().unwrap();
let output_path = temp_dir.path().join("output.pdf");
let digest = CacheDigest::with_values(1234567890, 999, "sha256:abc");
let cache = ProcessingCache::new(digest, ProcessingResult::default());
cache.save(&output_path).unwrap();
let cache_path = ProcessingCache::cache_path(&output_path);
assert!(cache_path.exists());
ProcessingCache::delete(&output_path).unwrap();
assert!(!cache_path.exists());
}
#[test]
fn test_processing_cache_delete_nonexistent() {
let result = ProcessingCache::delete("/nonexistent/file.pdf");
assert!(result.is_ok());
}
#[test]
fn test_should_skip_with_force() {
let temp_dir = tempfile::tempdir().unwrap();
let source_path = temp_dir.path().join("source.pdf");
let output_path = temp_dir.path().join("output.pdf");
fs::write(&source_path, "source").unwrap();
fs::write(&output_path, "output").unwrap();
let digest = CacheDigest::new(&source_path, "{}").unwrap();
let cache = ProcessingCache::new(digest, ProcessingResult::default());
cache.save(&output_path).unwrap();
let result = should_skip_processing(&source_path, &output_path, "{}", true);
assert!(result.is_none());
}
#[test]
fn test_should_skip_no_output() {
let temp_dir = tempfile::tempdir().unwrap();
let source_path = temp_dir.path().join("source.pdf");
let output_path = temp_dir.path().join("output.pdf");
fs::write(&source_path, "source").unwrap();
let result = should_skip_processing(&source_path, &output_path, "{}", false);
assert!(result.is_none());
}
#[test]
fn test_should_skip_no_cache() {
let temp_dir = tempfile::tempdir().unwrap();
let source_path = temp_dir.path().join("source.pdf");
let output_path = temp_dir.path().join("output.pdf");
fs::write(&source_path, "source").unwrap();
fs::write(&output_path, "output").unwrap();
let result = should_skip_processing(&source_path, &output_path, "{}", false);
assert!(result.is_none());
}
#[test]
fn test_should_skip_valid_cache() {
let temp_dir = tempfile::tempdir().unwrap();
let source_path = temp_dir.path().join("source.pdf");
let output_path = temp_dir.path().join("output.pdf");
fs::write(&source_path, "source").unwrap();
fs::write(&output_path, "output").unwrap();
let options = r#"{"dpi": 300}"#;
let digest = CacheDigest::new(&source_path, options).unwrap();
let result = ProcessingResult::new(10, None, false, 5.0, 1000);
let cache = ProcessingCache::new(digest, result);
cache.save(&output_path).unwrap();
let skip_result = should_skip_processing(&source_path, &output_path, options, false);
assert!(skip_result.is_some());
assert_eq!(skip_result.unwrap().result.page_count, 10);
}
#[test]
fn test_should_skip_options_changed() {
let temp_dir = tempfile::tempdir().unwrap();
let source_path = temp_dir.path().join("source.pdf");
let output_path = temp_dir.path().join("output.pdf");
fs::write(&source_path, "source").unwrap();
fs::write(&output_path, "output").unwrap();
let digest = CacheDigest::new(&source_path, r#"{"dpi": 300}"#).unwrap();
let cache = ProcessingCache::new(digest, ProcessingResult::default());
cache.save(&output_path).unwrap();
let result = should_skip_processing(&source_path, &output_path, r#"{"dpi": 600}"#, false);
assert!(result.is_none());
}
}