use std::path::PathBuf;
use async_trait::async_trait;
use serde::Deserialize;
use crate::options::OptionSchema;
use crate::paths;
use crate::payload::{Body, ImageData, Payload};
use crate::render::Shape;
use super::super::{FetchContext, FetchError, Fetcher, Safety};
use super::client::{http, resolve_authenticated_user};
use super::common::cache_key;
const AVATAR_BASE: &str = "https://github.com";
const DEFAULT_SIZE: u32 = 200;
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "user",
type_hint: "string (github login)",
required: false,
default: Some("authenticated token user"),
description: "GitHub login to fetch the avatar for. Falls back to the `GITHUB_USER` env var, then to the login that owns `GH_TOKEN` / `GITHUB_TOKEN`.",
},
OptionSchema {
name: "size",
type_hint: "integer (px)",
required: false,
default: Some("200"),
description: "Requested avatar edge size in pixels. GitHub serves a square image of this size.",
},
];
pub struct GithubAvatar;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Options {
#[serde(default)]
pub user: Option<String>,
#[serde(default)]
pub size: Option<u32>,
}
#[async_trait]
impl Fetcher for GithubAvatar {
fn name(&self) -> &str {
"github_avatar"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"A GitHub user's avatar PNG, downloaded once per week and rendered as an image. Useful as the visual hero next to a `github_user` text block."
}
fn shapes(&self) -> &[Shape] {
&[Shape::Image]
}
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 user = resolve_user(opts.user.as_deref())
.await
.map_err(FetchError::Failed)?;
let size = opts.size.unwrap_or(DEFAULT_SIZE);
let path = download_avatar(&user, size).await?;
Ok(payload(Body::Image(ImageData {
path: path.to_string_lossy().into_owned(),
})))
}
}
async fn download_avatar(user: &str, size: u32) -> Result<PathBuf, FetchError> {
let out_dir = avatar_dir()
.ok_or_else(|| FetchError::Failed("$HOME not available for avatar cache".into()))?;
std::fs::create_dir_all(&out_dir)
.map_err(|e| FetchError::Failed(format!("create avatar cache dir: {e}")))?;
let url = format!("{AVATAR_BASE}/{user}.png?size={size}");
let res = http()
.get(&url)
.send()
.await
.map_err(|e| FetchError::Failed(format!("avatar request failed: {e}")))?;
let status = res.status();
if !status.is_success() {
return Err(FetchError::Failed(format!("avatar {status}")));
}
let bytes = res
.bytes()
.await
.map_err(|e| FetchError::Failed(format!("avatar body: {e}")))?;
let ext = image_extension(&bytes);
let path = out_dir.join(format!("{user}-{size}.{ext}"));
std::fs::write(&path, &bytes).map_err(|e| FetchError::Failed(format!("write avatar: {e}")))?;
Ok(path)
}
fn image_extension(bytes: &[u8]) -> &'static str {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
"png"
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
"jpg"
} else {
"png"
}
}
fn avatar_dir() -> Option<PathBuf> {
paths::cache_dir().map(|d| d.join("avatars"))
}
async fn resolve_user(explicit: Option<&str>) -> Result<String, String> {
if let Some(u) = explicit.filter(|s| !s.is_empty()) {
return Ok(u.into());
}
if let Ok(u) = std::env::var("GITHUB_USER")
&& !u.is_empty()
{
return Ok(u);
}
resolve_authenticated_user()
.await
.map_err(|e| format!("resolve github user: {e}"))
}
fn parse_options<T: serde::de::DeserializeOwned + Default>(
raw: Option<&toml::Value>,
) -> Result<T, String> {
match raw {
None => Ok(T::default()),
Some(value) => value
.clone()
.try_into::<T>()
.map_err(|e| format!("invalid options: {e}")),
}
}
fn payload(body: Body) -> Payload {
Payload {
icon: None,
status: None,
format: None,
body,
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::fetcher::github::client;
struct EnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
restore: Vec<(&'static str, Option<String>)>,
}
impl EnvGuard {
fn set(pairs: &[(&'static str, Option<&str>)]) -> Self {
let lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let restore = pairs
.iter()
.map(|(k, v)| {
let prev = std::env::var(k).ok();
match v {
Some(value) => unsafe { std::env::set_var(k, value) },
None => unsafe { std::env::remove_var(k) },
}
(*k, prev)
})
.collect();
Self {
_lock: lock,
restore,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for (k, v) in &self.restore {
match v {
Some(value) => unsafe { std::env::set_var(k, value) },
None => unsafe { std::env::remove_var(k) },
}
}
}
}
fn ctx(options: Option<&str>, format: Option<&str>) -> FetchContext {
FetchContext {
widget_id: "avatar".into(),
format: format.map(str::to_string),
timeout: Duration::from_secs(1),
file_format: None,
shape: Some(Shape::Image),
options: options.map(|raw| toml::from_str(raw).unwrap()),
timezone: None,
locale: None,
}
}
#[test]
fn fetcher_contract_exposes_image_surface() {
let fetcher = GithubAvatar;
assert_eq!(
fetcher.description(),
"A GitHub user's avatar PNG, downloaded once per week and rendered as an image. Useful as the visual hero next to a `github_user` text block."
);
assert_eq!(fetcher.shapes(), &[Shape::Image]);
assert_eq!(fetcher.option_schemas().len(), 2);
assert_eq!(fetcher.option_schemas()[0].name, "user");
assert_eq!(fetcher.option_schemas()[1].name, "size");
assert_eq!(fetcher.sample_body(Shape::Image), None);
}
#[test]
fn cache_key_changes_with_options_and_format() {
let fetcher = GithubAvatar;
let base = fetcher.cache_key(&ctx(Some("user = 'alice'"), None));
let changed_options = fetcher.cache_key(&ctx(Some("user = 'alice'\nsize = 128"), None));
let changed_format = fetcher.cache_key(&ctx(Some("user = 'alice'"), Some("raw")));
assert_ne!(base, changed_options);
assert_ne!(base, changed_format);
}
#[test]
fn avatar_dir_lives_under_cache_dir() {
let tmp = tempfile::tempdir().unwrap();
let _g = EnvGuard::set(&[("SPLASHBOARD_HOME", Some(tmp.path().to_str().unwrap()))]);
assert_eq!(avatar_dir(), Some(tmp.path().join("cache").join("avatars")));
}
#[test]
fn payload_wraps_body_without_metadata() {
let body = Body::Image(ImageData {
path: "x.png".into(),
});
assert_eq!(
payload(body.clone()),
Payload {
icon: None,
status: None,
format: None,
body,
}
);
}
#[tokio::test]
async fn resolve_user_prefers_explicit_option() {
let _g = EnvGuard::set(&[("GITHUB_USER", Some("from-env"))]);
assert_eq!(resolve_user(Some("from-opt")).await.unwrap(), "from-opt");
}
#[tokio::test]
async fn resolve_user_falls_back_to_env() {
let _g = EnvGuard::set(&[("GITHUB_USER", Some("env-user"))]);
assert_eq!(resolve_user(None).await.unwrap(), "env-user");
}
#[tokio::test]
async fn resolve_user_empty_option_falls_through_to_env() {
let _g = EnvGuard::set(&[("GITHUB_USER", Some("env-user"))]);
assert_eq!(resolve_user(Some("")).await.unwrap(), "env-user");
}
#[tokio::test]
async fn resolve_user_without_env_or_token_surfaces_token_hint() {
let _g = EnvGuard::set(&[
("GITHUB_USER", None),
("GH_TOKEN", None),
("GITHUB_TOKEN", None),
]);
client::clear_authenticated_user_cache();
let err = resolve_user(None).await.unwrap_err();
client::clear_authenticated_user_cache();
assert!(err.contains("GH_TOKEN"), "unexpected error: {err}");
}
#[test]
fn options_default_when_absent() {
let opts: Options = parse_options(None).unwrap();
assert!(opts.user.is_none());
assert!(opts.size.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());
}
#[tokio::test]
async fn fetch_rejects_unknown_options() {
let fetcher = GithubAvatar;
let err = fetcher
.fetch(&ctx(Some("bogus = 1"), None))
.await
.unwrap_err();
assert!(matches!(err, FetchError::Failed(msg) if msg.contains("invalid options")));
}
#[tokio::test]
async fn fetch_uses_default_size_and_surfaces_cache_dir_failure() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("not-a-dir");
std::fs::write(&home, "x").unwrap();
let _g = EnvGuard::set(&[
("SPLASHBOARD_HOME", Some(home.to_str().unwrap())),
("GITHUB_USER", Some("env-user")),
]);
let fetcher = GithubAvatar;
let err = fetcher.fetch(&ctx(None, None)).await.unwrap_err();
assert!(matches!(err, FetchError::Failed(msg) if msg.contains("create avatar cache dir")));
}
#[test]
fn image_extension_detects_png() {
assert_eq!(image_extension(b"\x89PNG\r\n\x1a\nrest"), "png");
}
#[test]
fn image_extension_detects_jpeg() {
assert_eq!(image_extension(&[0xff, 0xd8, 0xff, 0xdb, 0x00]), "jpg");
}
#[test]
fn image_extension_unknown_falls_back_to_png() {
assert_eq!(image_extension(&[0, 0, 0, 0]), "png");
}
#[tokio::test]
#[ignore]
async fn live_downloads_image_under_cache_dir() {
let tmp = tempfile::tempdir().unwrap();
let _g = EnvGuard::set(&[("SPLASHBOARD_HOME", Some(tmp.path().to_str().unwrap()))]);
let path = download_avatar("unhappychoice", 64).await.unwrap();
eprintln!("wrote {}", path.display());
let bytes = std::fs::read(&path).unwrap();
assert!(
bytes.len() > 256,
"avatar suspiciously small: {} bytes",
bytes.len()
);
let ext = path
.extension()
.and_then(|s| s.to_str())
.unwrap_or_default();
assert!(matches!(ext, "png" | "jpg"), "unexpected extension {ext:?}");
match ext {
"png" => assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n"),
"jpg" => assert_eq!(&bytes[..3], &[0xff, 0xd8, 0xff]),
_ => unreachable!(),
}
}
}