use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::SystemTimeError;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct CacheEntry {
pub(super) mtime_unix_ms: u128,
pub(super) theme: serde_json::Value,
}
pub(super) fn config_path_hash(path: &Path) -> String {
let mut hasher = Sha256::new();
hasher.update(path.as_os_str().as_encoded_bytes());
let digest = hasher.finalize();
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
const TABLE: &[u8; 16] = b"0123456789abcdef";
let upper = TABLE[(byte >> 4) as usize];
let lower = TABLE[(byte & 0x0f) as usize];
hex.push(char::from(upper));
hex.push(char::from(lower));
}
hex
}
pub(super) fn cache_path_for(config_path: &Path, dir: &Path) -> PathBuf {
let hash = config_path_hash(config_path);
dir.join(format!("{hash}.json"))
}
pub(super) fn mtime_unix_ms(path: &Path) -> Result<u128, MtimeError> {
let meta = fs::metadata(path).map_err(MtimeError::Io)?;
let modified = meta.modified().map_err(MtimeError::Io)?;
let dur = modified
.duration_since(std::time::UNIX_EPOCH)
.map_err(MtimeError::SystemTime)?;
Ok(dur.as_millis())
}
#[derive(Debug)]
pub(super) enum MtimeError {
Io(io::Error),
SystemTime(SystemTimeError),
}
impl std::fmt::Display for MtimeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(err) => write!(f, "{err}"),
Self::SystemTime(err) => write!(f, "{err}"),
}
}
}
pub(super) fn read(config_path: &Path, dir: &Path) -> Option<CacheEntry> {
let mtime = mtime_unix_ms(config_path).ok()?;
let cache_path = cache_path_for(config_path, dir);
let bytes = fs::read(&cache_path).ok()?;
let entry: CacheEntry = serde_json::from_slice(&bytes).ok()?;
if entry.mtime_unix_ms == mtime {
Some(entry)
} else {
None
}
}
pub(super) fn write(
config_path: &Path,
theme: &serde_json::Value,
dir: &Path,
) -> Result<(), io::Error> {
let mtime = mtime_unix_ms(config_path).map_err(|err| match err {
MtimeError::Io(io) => io,
MtimeError::SystemTime(_) => io::Error::other("config mtime predates the Unix epoch"),
})?;
let entry = CacheEntry {
mtime_unix_ms: mtime,
theme: theme.clone(),
};
let cache_path = cache_path_for(config_path, dir);
if let Some(parent) = cache_path.parent() {
fs::create_dir_all(parent)?;
}
let serialized = serde_json::to_vec(&entry).map_err(io::Error::other)?;
let tmp_path = cache_path.with_extension("json.tmp");
fs::write(&tmp_path, &serialized)?;
fs::rename(&tmp_path, &cache_path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_path_hash_is_stable_and_64_chars() {
let path = Path::new("/tmp/example/tailwind.config.js");
let h1 = config_path_hash(path);
let h2 = config_path_hash(path);
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64);
assert!(
h1.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
}
#[test]
fn config_path_hash_distinguishes_paths() {
let a = config_path_hash(Path::new("/a/tailwind.config.js"));
let b = config_path_hash(Path::new("/b/tailwind.config.js"));
assert_ne!(a, b);
}
#[test]
fn cache_round_trip_through_temp_dir() {
let dir = tempfile::tempdir().expect("tempdir");
let cfg_path = dir.path().join("tailwind.config.js");
std::fs::write(&cfg_path, "module.exports = {};").expect("write config");
let theme = serde_json::json!({"colors": {"red": "#ff0000"}});
write(&cfg_path, &theme, dir.path()).expect("write cache");
let entry = read(&cfg_path, dir.path()).expect("hit cache");
assert_eq!(entry.theme, theme);
}
#[test]
fn cache_miss_when_mtime_changes() {
let dir = tempfile::tempdir().expect("tempdir");
let cfg_path = dir.path().join("tailwind.config.js");
std::fs::write(&cfg_path, "module.exports = {};").expect("write config");
let theme = serde_json::json!({"colors": {"red": "#ff0000"}});
write(&cfg_path, &theme, dir.path()).expect("write cache");
let later = std::time::UNIX_EPOCH + std::time::Duration::from_secs(2_000_000_000);
let file = std::fs::OpenOptions::new()
.write(true)
.open(&cfg_path)
.expect("open");
file.set_modified(later).expect("set mtime");
drop(file);
assert!(
read(&cfg_path, dir.path()).is_none(),
"mtime change should invalidate cache"
);
}
}