use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::Duration;
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use crate::fetcher::github::common::{cache_key, parse_options, payload};
use crate::fetcher::{FetchContext, FetchError, Fetcher, Safety};
use crate::options::OptionSchema;
use crate::paths;
use crate::payload::{Body, ImageData, Payload};
use crate::render::Shape;
const CATAAS_BASE: &str = "https://cataas.com";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const USER_AGENT: &str = concat!("splashboard/", env!("CARGO_PKG_VERSION"));
const MAX_BYTES: usize = 5 * 1024 * 1024;
const SHAPES: &[Shape] = &[Shape::Image];
const OPTION_SCHEMAS: &[OptionSchema] = &[OptionSchema {
name: "tag",
type_hint: "string",
required: false,
default: None,
description: "cataas.com tag filter (e.g. `cute`, `orange`, `kitten`). Omit for any cat.",
}];
pub struct RandomCatFetcher;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Options {
#[serde(default)]
pub tag: Option<String>,
}
#[async_trait]
impl Fetcher for RandomCatFetcher {
fn name(&self) -> &str {
"random_cat"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"A random cat picture from cataas.com, downloaded each refresh cycle and rendered as an image. Optional `tag` filters by mood (`cute` / `orange` / `kitten` / …)."
}
fn refresh_interval(&self) -> u64 {
60 * 60
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn cache_key(&self, ctx: &FetchContext) -> String {
let extra = ctx
.options
.as_ref()
.and_then(|v| toml::to_string(v).ok())
.unwrap_or_default();
cache_key(self.name(), ctx, &extra)
}
fn sample_body(&self, _shape: Shape) -> Option<Body> {
None
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let opts: Options = parse_options(ctx.options.as_ref()).map_err(FetchError::Failed)?;
let key = self.cache_key(ctx);
let path = download_cat(opts.tag.as_deref(), &key).await?;
Ok(payload(Body::Image(ImageData {
path: path.to_string_lossy().into_owned(),
})))
}
}
async fn download_cat(tag: Option<&str>, key: &str) -> Result<PathBuf, FetchError> {
let out_dir =
cat_dir().ok_or_else(|| FetchError::Failed("$HOME not available for cat cache".into()))?;
std::fs::create_dir_all(&out_dir)
.map_err(|e| FetchError::Failed(format!("create cat cache dir: {e}")))?;
let url = build_url(tag);
let bytes = fetch_bytes(&url).await?;
let ext = image_extension(&bytes)
.ok_or_else(|| FetchError::Failed("unrecognized image format from cataas".into()))?;
remove_stale(&out_dir, key);
let path = out_dir.join(format!("{key}.{ext}"));
std::fs::write(&path, &bytes).map_err(|e| FetchError::Failed(format!("write cat: {e}")))?;
Ok(path)
}
fn build_url(tag: Option<&str>) -> String {
match tag.map(str::trim).filter(|s| !s.is_empty()) {
Some(t) => format!("{CATAAS_BASE}/cat/{}", encode_tag(t)),
None => format!("{CATAAS_BASE}/cat"),
}
}
fn encode_tag(tag: &str) -> String {
tag.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b',' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
async fn fetch_bytes(url: &str) -> Result<Vec<u8>, FetchError> {
let res = http()
.get(url)
.send()
.await
.map_err(|e| FetchError::Failed(format!("cat request failed: {e}")))?;
let status = res.status();
if !status.is_success() {
return Err(FetchError::Failed(format!("cat {status}")));
}
let bytes = res
.bytes()
.await
.map_err(|e| FetchError::Failed(format!("cat body: {e}")))?;
if bytes.len() > MAX_BYTES {
return Err(FetchError::Failed(format!(
"cat body too large: {} bytes",
bytes.len()
)));
}
Ok(bytes.to_vec())
}
fn http() -> &'static Client {
static CLIENT: OnceLock<Client> = OnceLock::new();
CLIENT.get_or_init(|| {
Client::builder()
.user_agent(USER_AGENT)
.timeout(REQUEST_TIMEOUT)
.gzip(true)
.build()
.expect("reqwest client should build with default config")
})
}
fn image_extension(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Some("png")
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
Some("jpg")
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
Some("gif")
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
Some("webp")
} else {
None
}
}
fn cat_dir() -> Option<PathBuf> {
paths::cache_dir().map(|d| d.join("cats"))
}
fn remove_stale(dir: &std::path::Path, key: &str) {
for ext in ["png", "jpg", "gif", "webp"] {
let _ = std::fs::remove_file(dir.join(format!("{key}.{ext}")));
}
}
#[cfg(test)]
mod tests {
use std::io::{Read, Write};
use std::net::TcpListener;
use super::*;
fn ctx(shape: Option<Shape>, options: Option<toml::Value>) -> FetchContext {
FetchContext {
widget_id: "w".into(),
timeout: Duration::from_secs(1),
shape,
options,
..Default::default()
}
}
fn restore_home(previous: Option<String>) {
unsafe {
match previous {
Some(value) => std::env::set_var("SPLASHBOARD_HOME", value),
None => std::env::remove_var("SPLASHBOARD_HOME"),
}
}
}
struct TestServer {
url: String,
handle: std::thread::JoinHandle<()>,
}
impl TestServer {
fn start(status: &str, body: Vec<u8>) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let status = status.to_string();
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0u8; 1024];
let _ = stream.read(&mut request);
let headers = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(headers.as_bytes()).unwrap();
stream.write_all(&body).unwrap();
});
Self {
url: format!("http://{addr}"),
handle,
}
}
fn finish(self) {
self.handle.join().unwrap();
}
}
#[test]
fn options_default_when_absent() {
let opts: Options = parse_options(None).unwrap();
assert!(opts.tag.is_none());
}
#[test]
fn options_reject_unknown_keys() {
let raw: toml::Value = toml::from_str("bogus = 1").unwrap();
assert!(parse_options::<Options>(Some(&raw)).is_err());
}
#[test]
fn options_accept_tag() {
let raw: toml::Value = toml::from_str("tag = \"cute\"").unwrap();
let opts: Options = parse_options(Some(&raw)).unwrap();
assert_eq!(opts.tag.as_deref(), Some("cute"));
}
#[test]
fn fetcher_exposes_catalog_metadata() {
let fetcher = RandomCatFetcher;
assert_eq!(fetcher.name(), "random_cat");
assert_eq!(fetcher.safety(), Safety::Safe);
assert_eq!(fetcher.default_shape(), Shape::Image);
assert_eq!(fetcher.shapes(), SHAPES);
assert_eq!(
fetcher
.option_schemas()
.iter()
.map(|schema| schema.name)
.collect::<Vec<_>>(),
vec!["tag"]
);
assert!(fetcher.description().contains("cataas.com"));
assert!(fetcher.sample_body(Shape::Image).is_none());
}
#[test]
fn build_url_without_tag() {
assert_eq!(build_url(None), "https://cataas.com/cat");
}
#[test]
fn build_url_with_tag() {
assert_eq!(build_url(Some("cute")), "https://cataas.com/cat/cute");
}
#[test]
fn build_url_treats_empty_tag_as_none() {
assert_eq!(build_url(Some("")), "https://cataas.com/cat");
assert_eq!(build_url(Some(" ")), "https://cataas.com/cat");
}
#[test]
fn build_url_encodes_special_chars() {
assert_eq!(
build_url(Some("orange tabby")),
"https://cataas.com/cat/orange%20tabby"
);
}
#[test]
fn build_url_keeps_comma_for_multi_tag() {
assert_eq!(
build_url(Some("cute,fat")),
"https://cataas.com/cat/cute,fat"
);
}
#[test]
fn build_url_keeps_digits_unencoded() {
assert_eq!(build_url(Some("cute123")), "https://cataas.com/cat/cute123");
}
#[test]
fn image_extension_detects_png() {
assert_eq!(image_extension(b"\x89PNG\r\n\x1a\nrest"), Some("png"));
}
#[test]
fn image_extension_detects_jpeg() {
assert_eq!(
image_extension(&[0xff, 0xd8, 0xff, 0xdb, 0x00]),
Some("jpg")
);
}
#[test]
fn image_extension_detects_gif() {
assert_eq!(image_extension(b"GIF89a..."), Some("gif"));
assert_eq!(image_extension(b"GIF87a..."), Some("gif"));
}
#[test]
fn image_extension_detects_webp() {
let mut bytes = vec![];
bytes.extend_from_slice(b"RIFF");
bytes.extend_from_slice(&[0u8; 4]);
bytes.extend_from_slice(b"WEBPVP8 ");
assert_eq!(image_extension(&bytes), Some("webp"));
}
#[test]
fn image_extension_unknown_returns_none() {
assert!(image_extension(&[0, 0, 0, 0]).is_none());
assert!(image_extension(b"<html>").is_none());
}
#[test]
fn cache_key_changes_with_tag() {
let mut ctx = FetchContext {
widget_id: "w".into(),
timeout: Duration::from_secs(1),
shape: Some(Shape::Image),
..Default::default()
};
let none_key = RandomCatFetcher.cache_key(&ctx);
ctx.options = Some(toml::Value::Table({
let mut t = toml::value::Table::new();
t.insert("tag".into(), toml::Value::String("cute".into()));
t
}));
let cute_key = RandomCatFetcher.cache_key(&ctx);
assert_ne!(none_key, cute_key);
}
#[test]
fn remove_stale_removes_other_extensions() {
let tmp = tempfile::tempdir().unwrap();
let key = "abc";
for ext in ["png", "jpg", "gif", "webp"] {
std::fs::write(tmp.path().join(format!("{key}.{ext}")), b"x").unwrap();
}
remove_stale(tmp.path(), key);
for ext in ["png", "jpg", "gif", "webp"] {
assert!(
!tmp.path().join(format!("{key}.{ext}")).exists(),
"stale {ext} not removed"
);
}
}
#[test]
fn cat_dir_uses_cache_layout_under_env_override() {
let _lock = paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let previous = std::env::var("SPLASHBOARD_HOME").ok();
unsafe { std::env::set_var("SPLASHBOARD_HOME", tmp.path()) };
assert_eq!(cat_dir(), Some(tmp.path().join("cache").join("cats")));
restore_home(previous);
}
#[test]
fn restore_home_reapplies_previous_value_when_present() {
let _lock = paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let prior = std::env::var("SPLASHBOARD_HOME").ok();
unsafe { std::env::set_var("SPLASHBOARD_HOME", "/tmp/splashboard-cat-restore-prior") };
let captured = std::env::var("SPLASHBOARD_HOME").ok();
unsafe { std::env::set_var("SPLASHBOARD_HOME", "/tmp/splashboard-cat-restore-other") };
restore_home(captured);
assert_eq!(
std::env::var("SPLASHBOARD_HOME").ok().as_deref(),
Some("/tmp/splashboard-cat-restore-prior")
);
restore_home(prior);
}
#[tokio::test]
async fn fetch_rejects_unknown_options_before_network() {
let err = RandomCatFetcher
.fetch(&ctx(
Some(Shape::Image),
Some(toml::from_str("bogus = true").unwrap()),
))
.await
.unwrap_err();
assert!(format!("{err}").contains("unknown field"));
}
#[tokio::test]
async fn fetch_bytes_rejects_malformed_url() {
let err = fetch_bytes("https://bad host").await.unwrap_err();
assert!(format!("{err}").contains("cat request failed"));
}
#[tokio::test]
async fn fetch_bytes_returns_response_body_for_success_status() {
let body = b"\x89PNG\r\n\x1a\ncat".to_vec();
let server = TestServer::start("200 OK", body.clone());
let bytes = fetch_bytes(&server.url).await.unwrap();
server.finish();
assert_eq!(bytes, body);
}
#[tokio::test]
async fn fetch_bytes_rejects_non_success_status() {
let server = TestServer::start("503 Service Unavailable", b"no cat today".to_vec());
let err = fetch_bytes(&server.url).await.unwrap_err();
server.finish();
assert!(format!("{err}").contains("cat 503"));
}
#[tokio::test]
async fn fetch_bytes_rejects_oversized_body() {
let server = TestServer::start("200 OK", vec![b'x'; MAX_BYTES + 1]);
let err = fetch_bytes(&server.url).await.unwrap_err();
server.finish();
assert!(format!("{err}").contains("cat body too large"));
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn fetch_surfaces_cache_dir_creation_failure_before_network() {
let _lock = paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("not-a-dir");
std::fs::write(&file, b"x").unwrap();
let previous = std::env::var("SPLASHBOARD_HOME").ok();
unsafe { std::env::set_var("SPLASHBOARD_HOME", &file) };
let err = RandomCatFetcher
.fetch(&ctx(
Some(Shape::Image),
Some(toml::from_str("tag = \"cute\"").unwrap()),
))
.await
.unwrap_err();
assert!(format!("{err}").contains("create cat cache dir"));
restore_home(previous);
}
#[tokio::test]
#[ignore]
#[allow(clippy::await_holding_lock)]
async fn live_downloads_a_real_cat() {
let tmp = tempfile::tempdir().unwrap();
let _lock = paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("SPLASHBOARD_HOME", tmp.path()) };
let path = download_cat(None, "test-cat").await.unwrap();
eprintln!("wrote {}", path.display());
let bytes = std::fs::read(&path).unwrap();
assert!(
bytes.len() > 256,
"cat suspiciously small: {} bytes",
bytes.len()
);
assert!(
image_extension(&bytes).is_some(),
"unrecognized image format"
);
unsafe { std::env::remove_var("SPLASHBOARD_HOME") };
}
}