use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::{Duration, SystemTime};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::models::{
AbilityInfo, EvolutionTree, PokemonDetail, PokemonEntry, Sprite, SpriteVariant,
};
const VERSION: u32 = 4;
const LIST_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
const MISSING_SPRITE_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
pub struct CachedList {
pub entries: Vec<PokemonEntry>,
pub fresh: bool,
}
#[derive(Serialize, Deserialize)]
pub struct CachedBundle {
pub detail: PokemonDetail,
pub evolution: EvolutionTree,
}
#[derive(Serialize, Deserialize)]
struct Envelope<T> {
version: u32,
data: T,
}
pub async fn load_list() -> Option<CachedList> {
let path = root()?.join("list.json");
let entries = read_json(&path).await?;
Some(CachedList {
entries,
fresh: age(&path).await.is_some_and(|a| a < LIST_TTL),
})
}
pub async fn store_list(entries: &[PokemonEntry]) {
let Some(path) = root().map(|r| r.join("list.json")) else {
return;
};
write_json(&path, &entries.to_vec()).await;
}
pub async fn load_bundle(name: &str) -> Option<CachedBundle> {
let path = root()?.join("species").join(format!("{}.json", slug(name)));
read_json(&path).await
}
pub async fn store_bundle(name: &str, detail: &PokemonDetail, evolution: &EvolutionTree) {
let Some(path) = root().map(|r| r.join("species").join(format!("{}.json", slug(name)))) else {
return;
};
let bundle = CachedBundle {
detail: detail.clone(),
evolution: evolution.clone(),
};
write_json(&path, &bundle).await;
}
pub async fn load_ability(name: &str) -> Option<AbilityInfo> {
let path = ability_path(name)?;
read_json(&path).await
}
pub async fn store_ability(name: &str, info: &AbilityInfo) {
let Some(path) = ability_path(name) else {
return;
};
write_json(&path, info).await;
}
pub async fn load_type_members(type_name: &str) -> Option<Vec<String>> {
let path = type_path(type_name)?;
read_json(&path).await
}
pub async fn store_type_members(type_name: &str, members: &[String]) {
let Some(path) = type_path(type_name) else {
return;
};
write_json(&path, &members.to_vec()).await;
}
pub async fn load_sprite(name: &str, variant: SpriteVariant) -> Option<Sprite> {
let path = sprite_path(name, variant)?;
let bytes = tokio::fs::read(&path).await.ok()?;
let image = image::load_from_memory(&bytes).ok()?.to_rgba8();
let (width, height) = image.dimensions();
Some(Sprite {
width,
height,
pixels: image.pixels().map(|p| p.0).collect(),
})
}
pub async fn store_sprite(name: &str, sprite: &Sprite, variant: SpriteVariant) {
let Some(path) = sprite_path(name, variant) else {
return;
};
let Some(bytes) = encode_png(sprite) else {
return;
};
write_atomic(&path, &bytes).await;
}
pub async fn store_missing_sprite(name: &str, variant: SpriteVariant) {
let Some(path) = sprite_path(name, variant) else {
return;
};
write_atomic(&path, &[]).await;
}
pub async fn has_sprite_answer(name: &str, variant: SpriteVariant) -> bool {
match sprite_path(name, variant) {
Some(path) => sprite_answer_is_current(&path).await,
None => false,
}
}
async fn sprite_answer_is_current(path: &Path) -> bool {
let Ok(meta) = tokio::fs::metadata(path).await else {
return false; };
if meta.len() > 0 {
return true;
}
age(path).await.is_some_and(|a| a < MISSING_SPRITE_TTL)
}
pub async fn load_default_variety(species: &str) -> Option<String> {
let path = variety_path(species)?;
let name = tokio::fs::read_to_string(&path).await.ok()?;
let name = name.trim().to_string();
(!name.is_empty()).then_some(name)
}
pub async fn store_default_variety(species: &str, variety: &str) {
let Some(path) = variety_path(species) else {
return;
};
write_atomic(&path, variety.as_bytes()).await;
}
pub async fn load_translation(name: &str, lang: &str) -> Option<String> {
let path = translation_path(name, lang)?;
tokio::fs::read_to_string(&path).await.ok()
}
pub async fn store_translation(name: &str, lang: &str, text: &str) {
let Some(path) = translation_path(name, lang) else {
return;
};
write_atomic(&path, text.as_bytes()).await;
}
fn root() -> Option<&'static Path> {
static ROOT: OnceLock<Option<PathBuf>> = OnceLock::new();
ROOT.get_or_init(|| {
let base = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
.or_else(|| std::env::var_os("LOCALAPPDATA").map(PathBuf::from))?;
Some(base.join("pokeductor"))
})
.as_deref()
}
fn ability_path(name: &str) -> Option<PathBuf> {
Some(
root()?
.join("abilities")
.join(format!("{}.json", slug(name))),
)
}
fn type_path(type_name: &str) -> Option<PathBuf> {
Some(
root()?
.join("types")
.join(format!("{}.json", slug(type_name))),
)
}
fn sprite_path(name: &str, variant: SpriteVariant) -> Option<PathBuf> {
Some(root()?.join("sprites").join(sprite_file(name, variant)))
}
fn sprite_file(name: &str, variant: SpriteVariant) -> String {
format!("{}{}.png", slug(name), variant.file_suffix())
}
fn variety_path(species: &str) -> Option<PathBuf> {
Some(
root()?
.join("varieties")
.join(format!("{}.txt", slug(species))),
)
}
fn translation_path(name: &str, lang: &str) -> Option<PathBuf> {
Some(
root()?
.join("translations")
.join(format!("{}.{}.txt", slug(name), slug(lang))),
)
}
fn slug(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect()
}
async fn read_json<T: DeserializeOwned>(path: &Path) -> Option<T> {
let bytes = tokio::fs::read(path).await.ok()?;
let envelope: Envelope<T> = serde_json::from_slice(&bytes).ok()?;
(envelope.version == VERSION).then_some(envelope.data)
}
async fn write_json<T: Serialize>(path: &Path, data: &T) {
let envelope = Envelope {
version: VERSION,
data,
};
if let Ok(bytes) = serde_json::to_vec(&envelope) {
write_atomic(path, &bytes).await;
}
}
async fn write_atomic(path: &Path, bytes: &[u8]) {
let Some(dir) = path.parent() else {
return;
};
if tokio::fs::create_dir_all(dir).await.is_err() {
return;
}
let tmp = path.with_extension(format!("tmp{}", std::process::id()));
if tokio::fs::write(&tmp, bytes).await.is_err() {
let _ = tokio::fs::remove_file(&tmp).await;
return;
}
if tokio::fs::rename(&tmp, path).await.is_err() {
let _ = tokio::fs::remove_file(&tmp).await;
}
}
async fn age(path: &Path) -> Option<Duration> {
let modified = tokio::fs::metadata(path).await.ok()?.modified().ok()?;
SystemTime::now().duration_since(modified).ok()
}
fn encode_png(sprite: &Sprite) -> Option<Vec<u8>> {
let flat: Vec<u8> = sprite.pixels.iter().flatten().copied().collect();
let buffer = image::RgbaImage::from_raw(sprite.width, sprite.height, flat)?;
let mut out = Vec::new();
image::DynamicImage::ImageRgba8(buffer)
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.ok()?;
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_keeps_ordinary_api_names_intact() {
assert_eq!(slug("bulbasaur"), "bulbasaur");
assert_eq!(slug("ho-oh"), "ho-oh");
assert_eq!(slug("raichu-alola"), "raichu-alola");
}
#[test]
fn slug_neutralises_path_separators() {
assert_eq!(slug("../../etc/passwd"), "______etc_passwd");
assert!(!slug("a/b").contains('/'));
}
#[test]
fn shiny_artwork_gets_its_own_filename() {
assert_eq!(sprite_file("pikachu", SpriteVariant::Normal), "pikachu.png");
assert_eq!(
sprite_file("pikachu", SpriteVariant::Shiny),
"pikachu.shiny.png"
);
}
#[test]
fn envelope_from_another_version_is_a_miss() {
let raw = br#"{"version":999,"data":[{"name":"bulbasaur","id":1}]}"#;
let envelope: Envelope<Vec<PokemonEntry>> = serde_json::from_slice(raw).unwrap();
assert_ne!(envelope.version, VERSION);
}
fn scratch_dir(label: &str) -> PathBuf {
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"pokeductor-test-{}-{label}-{n}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("scratch dir");
dir
}
fn backdate(path: &Path, by: Duration) {
let file = std::fs::File::options()
.write(true)
.open(path)
.expect("open for touch");
let when = SystemTime::now() - by;
file.set_times(std::fs::FileTimes::new().set_modified(when))
.expect("backdate mtime");
}
#[tokio::test]
async fn a_species_never_looked_up_reads_as_a_miss() {
let dir = scratch_dir("never");
assert!(!sprite_answer_is_current(&dir.join("absent.png")).await);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_fresh_no_artwork_answer_still_suppresses_the_request() {
let dir = scratch_dir("fresh");
let path = dir.join("marker.png");
std::fs::write(&path, []).expect("write marker");
assert!(sprite_answer_is_current(&path).await);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_stale_no_artwork_answer_is_asked_again() {
let dir = scratch_dir("stale");
let path = dir.join("marker.png");
std::fs::write(&path, []).expect("write marker");
backdate(&path, MISSING_SPRITE_TTL + Duration::from_secs(60));
assert!(!sprite_answer_is_current(&path).await);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_cached_sprite_never_expires() {
let dir = scratch_dir("kept");
let path = dir.join("sprite.png");
std::fs::write(&path, b"not really a png, but not empty").expect("write sprite");
backdate(&path, MISSING_SPRITE_TTL * 52);
assert!(sprite_answer_is_current(&path).await);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sprites_survive_a_png_round_trip() {
let sprite = Sprite {
width: 2,
height: 1,
pixels: vec![[255, 0, 0, 255], [0, 128, 255, 128]],
};
let bytes = encode_png(&sprite).unwrap();
let decoded = image::load_from_memory(&bytes).unwrap().to_rgba8();
assert_eq!(decoded.dimensions(), (2, 1));
assert_eq!(
decoded.pixels().map(|p| p.0).collect::<Vec<_>>(),
sprite.pixels
);
}
}