use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
use io_mbox::{index::MboxIndex, path::MboxFsPath};
use log::debug;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::email::envelope::Envelope;
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct MboxCache {
pub index: Option<MboxIndex>,
pub envelopes: BTreeMap<String, Envelope>,
}
impl MboxCache {
pub fn load(dir: Option<&Path>, path: &MboxFsPath) -> Self {
let Some(file) = dir.map(|dir| cache_file(dir, path)) else {
return Self::default();
};
let cache = fs::read(&file)
.map_err(|err| err.to_string())
.and_then(|bytes| serde_json::from_slice(&bytes).map_err(|err| err.to_string()));
match cache {
Ok(cache) => cache,
Err(err) => {
debug!("cannot load mbox cache {}: {err}", file.display());
Self::default()
}
}
}
pub fn save(&mut self, dir: Option<&Path>, path: &MboxFsPath) {
let Some(dir) = dir else {
return;
};
if let Some(index) = &self.index {
let ids: Vec<&str> = index.entries.iter().map(|e| e.id.as_str()).collect();
self.envelopes.retain(|id, _| ids.contains(&id.as_str()));
}
let file = cache_file(dir, path);
let tmp = file.with_extension("json.tmp");
let result = fs::create_dir_all(dir)
.and_then(|()| fs::write(&tmp, serde_json::to_vec(self).unwrap_or_default()))
.and_then(|()| fs::rename(&tmp, &file));
if let Err(err) = result {
debug!("cannot save mbox cache {}: {err}", file.display());
}
}
}
fn cache_file(dir: &Path, path: &MboxFsPath) -> PathBuf {
let path = fs::canonicalize(path.as_str()).unwrap_or_else(|_| PathBuf::from(path.as_str()));
let hash = Sha256::digest(path.to_string_lossy().as_bytes());
let name: String = hash[..16].iter().map(|b| format!("{b:02x}")).collect();
dir.join(format!("{name}.json"))
}
#[cfg(test)]
mod tests {
use std::{env, process};
use io_mbox::{entry::MboxEntry, path::MboxFsPath};
use super::*;
fn tempdir(name: &str) -> PathBuf {
let dir = env::temp_dir().join(format!("himalaya-mbox-cache-{}-{name}", process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn envelope(id: &str) -> Envelope {
Envelope {
id: id.into(),
message_id: None,
in_reply_to: Vec::new(),
flags: Default::default(),
subject: format!("subject {id}"),
from: Vec::new(),
to: Vec::new(),
date: None,
size: 1,
has_attachment: Some(false),
}
}
#[test]
fn round_trips_and_prunes_gone_messages() {
let dir = tempdir("round-trip");
let path = MboxFsPath::new("/nowhere/mbox");
let mut cache = MboxCache {
index: Some(MboxIndex {
entries: vec![MboxEntry {
id: "kept".into(),
..Default::default()
}],
..Default::default()
}),
envelopes: BTreeMap::from([
("kept".into(), envelope("kept")),
("gone".into(), envelope("gone")),
]),
};
cache.save(Some(dir.as_path()), &path);
let loaded = MboxCache::load(Some(dir.as_path()), &path);
assert_eq!(loaded.index, cache.index);
assert_eq!(loaded.envelopes.keys().collect::<Vec<_>>(), ["kept"]);
}
#[test]
fn missing_or_corrupt_caches_load_empty() {
let dir = tempdir("corrupt");
let path = MboxFsPath::new("/nowhere/mbox");
assert!(MboxCache::load(Some(dir.as_path()), &path).index.is_none());
assert!(MboxCache::load(None, &path).index.is_none());
fs::write(cache_file(dir.as_path(), &path), b"not json").unwrap();
assert!(MboxCache::load(Some(dir.as_path()), &path).index.is_none());
}
}