#![allow(dead_code)]
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use crate::options::OptionSchema;
use crate::payload::{Body, ErrorData, ErrorKind, Payload, Status};
use crate::render::Shape;
use crate::samples;
pub mod basic;
pub mod calendar;
pub mod claude;
pub mod clock;
pub mod code;
pub mod codex;
pub mod crypto_trending;
pub mod crypto_watchlist;
pub mod deal;
pub mod deariary;
pub mod feed;
pub mod forge_items;
pub mod git;
pub mod github;
pub mod gitlab;
pub mod hackernews;
pub mod huggingface_trending;
pub mod lastfm;
pub mod linear;
pub mod llm_pricing;
pub mod lobsters;
pub mod nasa_apod;
pub mod net;
pub mod news;
pub mod random_cat;
pub mod random_dog;
pub mod random_fortune;
pub mod random_quote;
pub mod read_store;
pub mod reddit;
pub mod rss;
pub mod static_text;
pub mod steam;
pub mod stock_watchlist;
pub mod system;
pub mod thumbnails;
pub mod todoist;
pub mod weather;
pub mod wikipedia;
pub mod youtube;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Safety {
Safe,
Network,
Exec,
}
#[derive(Debug, Clone)]
pub enum FetchError {
Unknown(String),
Failed(String),
}
impl fmt::Display for FetchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unknown(name) => write!(f, "unknown fetcher: {name}"),
Self::Failed(msg) => write!(f, "fetch failed: {msg}"),
}
}
}
impl std::error::Error for FetchError {}
#[derive(Debug, Clone, Default)]
pub struct FetchContext {
pub widget_id: String,
pub format: Option<String>,
pub timeout: Duration,
pub file_format: Option<String>,
pub shape: Option<Shape>,
pub options: Option<toml::Value>,
pub timezone: Option<String>,
pub locale: Option<String>,
}
#[async_trait]
pub trait Fetcher: Send + Sync {
fn name(&self) -> &str;
fn safety(&self) -> Safety;
fn description(&self) -> &'static str;
fn refresh_interval(&self) -> u64;
fn shapes(&self) -> &[Shape];
fn default_shape(&self) -> Shape {
self.shapes()[0]
}
fn cache_key(&self, ctx: &FetchContext) -> String {
default_cache_key(self.name(), ctx)
}
fn option_schemas(&self) -> &[OptionSchema] {
&[]
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
samples::canonical_sample(shape)
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError>;
}
pub trait RealtimeFetcher: Send + Sync {
fn name(&self) -> &str;
fn safety(&self) -> Safety;
fn description(&self) -> &'static str;
fn shapes(&self) -> &[Shape];
fn default_shape(&self) -> Shape {
self.shapes()[0]
}
fn option_schemas(&self) -> &[OptionSchema] {
&[]
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
samples::canonical_sample(shape)
}
fn compute(&self, ctx: &FetchContext) -> Payload;
}
#[derive(Debug, Clone)]
pub struct ShapeMismatch {
pub fetcher: String,
pub requested: Shape,
}
fn error_payload(kind: ErrorKind, message: String, detail: Option<&str>) -> Payload {
Payload {
icon: None,
status: Some(Status::Error),
format: None,
body: Body::Error(ErrorData {
kind,
message,
detail: detail.map(str::to_string),
}),
}
}
pub fn shape_mismatch_placeholder(err: &ShapeMismatch) -> Payload {
error_payload(
ErrorKind::ShapeMismatch,
format!("⚠ {} can't emit {}", err.fetcher, err.requested.as_str()),
Some("check `render` in config"),
)
}
pub fn unknown_fetcher_placeholder(name: &str) -> Payload {
error_payload(
ErrorKind::UnknownFetcher,
format!("⚠ unknown fetcher: {name}"),
Some("upgrade splashboard?"),
)
}
pub fn error_placeholder(msg: &str) -> Payload {
error_payload(
ErrorKind::Fetch,
format!("⚠ {msg}"),
Some("see $HOME/.splashboard/logs"),
)
}
pub fn timeout_placeholder() -> Payload {
error_payload(
ErrorKind::Timeout,
"⏱ timed out".into(),
Some("see $HOME/.splashboard/logs"),
)
}
pub fn default_cache_key(name: &str, ctx: &FetchContext) -> String {
use sha2::{Digest, Sha256};
let shape = ctx.shape.map(|s| s.as_str()).unwrap_or("");
let raw = format!("{}|{}|{}", name, shape, ctx.format.as_deref().unwrap_or(""));
let digest = Sha256::digest(raw.as_bytes());
let hex: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
format!("{name}-{hex}")
}
#[derive(Clone)]
pub enum RegisteredFetcher {
Cached(Arc<dyn Fetcher>),
Realtime(Arc<dyn RealtimeFetcher>),
}
impl RegisteredFetcher {
pub fn name(&self) -> &str {
match self {
Self::Cached(f) => f.name(),
Self::Realtime(f) => f.name(),
}
}
pub fn safety(&self) -> Safety {
match self {
Self::Cached(f) => f.safety(),
Self::Realtime(f) => f.safety(),
}
}
pub fn description(&self) -> &'static str {
match self {
Self::Cached(f) => f.description(),
Self::Realtime(f) => f.description(),
}
}
pub fn shapes(&self) -> Vec<Shape> {
match self {
Self::Cached(f) => f.shapes().to_vec(),
Self::Realtime(f) => f.shapes().to_vec(),
}
}
pub fn default_shape(&self) -> Shape {
match self {
Self::Cached(f) => f.default_shape(),
Self::Realtime(f) => f.default_shape(),
}
}
pub fn option_schemas(&self) -> &[OptionSchema] {
match self {
Self::Cached(f) => f.option_schemas(),
Self::Realtime(f) => f.option_schemas(),
}
}
pub fn sample_body(&self, shape: Shape) -> Option<Body> {
match self {
Self::Cached(f) => f.sample_body(shape),
Self::Realtime(f) => f.sample_body(shape),
}
}
pub fn as_cached(&self) -> Option<Arc<dyn Fetcher>> {
match self {
Self::Cached(f) => Some(f.clone()),
Self::Realtime(_) => None,
}
}
pub fn as_realtime(&self) -> Option<Arc<dyn RealtimeFetcher>> {
match self {
Self::Realtime(f) => Some(f.clone()),
Self::Cached(_) => None,
}
}
}
#[derive(Default, Clone)]
pub struct Registry {
fetchers: HashMap<String, RegisteredFetcher>,
}
impl Registry {
pub fn with_builtins() -> Self {
let mut r = Self::default();
r.register(Arc::new(static_text::StaticText));
r.register(Arc::new(read_store::ReadStoreFetcher));
r.register(Arc::new(rss::RssFetcher));
r.register(Arc::new(crypto_watchlist::CryptoWatchlistFetcher));
r.register(Arc::new(crypto_trending::CryptoTrendingFetcher));
r.register(Arc::new(stock_watchlist::StockWatchlistFetcher));
r.register(Arc::new(random_cat::RandomCatFetcher));
r.register(Arc::new(random_dog::RandomDogFetcher));
r.register(Arc::new(nasa_apod::NasaApodFetcher));
r.register(Arc::new(youtube::YoutubeChannelFetcher));
r.register(Arc::new(huggingface_trending::HuggingfaceTrendingFetcher));
for f in calendar::fetchers() {
r.register(f);
}
for f in claude::fetchers() {
r.register(f);
}
for f in codex::fetchers() {
r.register(f);
}
for f in deal::fetchers() {
r.register(f);
}
for f in deariary::fetchers() {
r.register(f);
}
for f in hackernews::fetchers() {
r.register(f);
}
for f in lastfm::fetchers() {
r.register(f);
}
for f in linear::fetchers() {
r.register(f);
}
for f in lobsters::fetchers() {
r.register(f);
}
for f in news::fetchers() {
r.register(f);
}
for f in todoist::fetchers() {
r.register(f);
}
for f in reddit::fetchers() {
r.register(f);
}
for f in steam::fetchers() {
r.register(f);
}
for f in weather::fetchers() {
r.register(f);
}
for f in wikipedia::fetchers() {
r.register(f);
}
for f in clock::realtime_fetchers() {
r.register_realtime(f);
}
r.register_realtime(Arc::new(random_quote::RandomQuoteFetcher));
r.register_realtime(Arc::new(random_fortune::RandomFortuneFetcher));
for f in system::realtime_fetchers() {
r.register_realtime(f);
}
for f in system::cached_fetchers() {
r.register(f);
}
for f in net::cached_fetchers() {
r.register(f);
}
for f in net::realtime_fetchers() {
r.register_realtime(f);
}
for f in git::fetchers() {
r.register(f);
}
for f in github::fetchers() {
r.register(f);
}
for f in gitlab::fetchers() {
r.register(f);
}
for f in code::fetchers() {
r.register(f);
}
for f in basic::realtime_fetchers() {
r.register_realtime(f);
}
r
}
pub fn register(&mut self, f: Arc<dyn Fetcher>) {
self.fetchers
.insert(f.name().to_string(), RegisteredFetcher::Cached(f));
}
pub fn register_realtime(&mut self, f: Arc<dyn RealtimeFetcher>) {
self.fetchers
.insert(f.name().to_string(), RegisteredFetcher::Realtime(f));
}
pub fn get(&self, name: &str) -> Option<RegisteredFetcher> {
self.fetchers.get(name).cloned()
}
pub fn get_cached(&self, name: &str) -> Option<Arc<dyn Fetcher>> {
self.get(name).and_then(|f| f.as_cached())
}
pub fn get_realtime(&self, name: &str) -> Option<Arc<dyn RealtimeFetcher>> {
self.get(name).and_then(|f| f.as_realtime())
}
pub fn names(&self) -> Vec<&str> {
self.fetchers.keys().map(String::as_str).collect()
}
pub fn sorted(&self) -> Vec<RegisteredFetcher> {
let mut entries: Vec<_> = self.fetchers.values().cloned().collect();
entries.sort_by(|a, b| a.name().cmp(b.name()));
entries
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::payload::{Body, TextData};
struct Dummy(&'static str, Safety);
#[async_trait]
impl Fetcher for Dummy {
fn name(&self) -> &str {
self.0
}
fn safety(&self) -> Safety {
self.1
}
fn description(&self) -> &'static str {
"test fixture"
}
fn refresh_interval(&self) -> u64 {
60
}
fn shapes(&self) -> &[Shape] {
&[Shape::Text]
}
async fn fetch(&self, _: &FetchContext) -> Result<Payload, FetchError> {
Ok(Payload {
icon: None,
status: None,
format: None,
body: Body::Text(TextData {
value: String::new(),
}),
})
}
}
struct DummyRealtime(&'static str, Safety);
impl RealtimeFetcher for DummyRealtime {
fn name(&self) -> &str {
self.0
}
fn safety(&self) -> Safety {
self.1
}
fn description(&self) -> &'static str {
"test fixture"
}
fn shapes(&self) -> &[Shape] {
&[Shape::Text]
}
fn compute(&self, _: &FetchContext) -> Payload {
Payload {
icon: None,
status: None,
format: None,
body: Body::Text(TextData {
value: String::new(),
}),
}
}
}
#[test]
fn registry_register_and_get() {
let mut r = Registry::default();
r.register(Arc::new(Dummy("x", Safety::Safe)));
assert!(r.get("x").is_some());
assert!(r.get("missing").is_none());
}
#[test]
fn registry_register_realtime_and_get() {
let mut r = Registry::default();
r.register_realtime(Arc::new(DummyRealtime("rt", Safety::Safe)));
let entry = r.get("rt").expect("must be present");
assert!(matches!(entry, RegisteredFetcher::Realtime(_)));
assert_eq!(entry.safety(), Safety::Safe);
}
#[test]
fn registry_name_collision_replaces() {
let mut r = Registry::default();
r.register(Arc::new(Dummy("x", Safety::Safe)));
r.register_realtime(Arc::new(DummyRealtime("x", Safety::Network)));
let entry = r.get("x").unwrap();
assert!(matches!(entry, RegisteredFetcher::Realtime(_)));
assert_eq!(entry.safety(), Safety::Network);
}
#[test]
fn as_cached_and_as_realtime_are_mutually_exclusive() {
let cached = RegisteredFetcher::Cached(Arc::new(Dummy("c", Safety::Safe)));
assert!(cached.as_cached().is_some());
assert!(cached.as_realtime().is_none());
let rt = RegisteredFetcher::Realtime(Arc::new(DummyRealtime("r", Safety::Safe)));
assert!(rt.as_realtime().is_some());
assert!(rt.as_cached().is_none());
}
#[test]
fn with_builtins_registers_basic_static_fetcher() {
let r = Registry::with_builtins();
assert!(r.get("basic_static").is_some());
}
#[test]
fn with_builtins_registers_basic_family_as_realtime() {
let r = Registry::with_builtins();
for name in [
"basic_links",
"basic_image",
"basic_badge",
"basic_ratio",
"basic_bars",
"basic_entries",
"basic_numbers",
"basic_points",
"basic_timeline",
"basic_calendar",
"basic_heatmap",
] {
let entry = r
.get(name)
.unwrap_or_else(|| panic!("{name} must be registered"));
assert!(
matches!(entry, RegisteredFetcher::Realtime(_)),
"{name} must be registered as realtime"
);
}
}
#[test]
fn with_builtins_registers_clock_as_realtime() {
let r = Registry::with_builtins();
let entry = r.get("clock").expect("clock must be registered");
assert!(matches!(entry, RegisteredFetcher::Realtime(_)));
}
#[tokio::test]
async fn dummy_fetcher_round_trip() {
let f: Arc<dyn Fetcher> = Arc::new(Dummy("d", Safety::Network));
let ctx = FetchContext {
widget_id: "w".into(),
format: None,
timeout: Duration::from_secs(1),
..Default::default()
};
let p = f.fetch(&ctx).await.unwrap();
assert!(matches!(p.body, Body::Text(_)));
assert_eq!(f.safety(), Safety::Network);
}
#[test]
fn fetch_error_display_covers_both_variants() {
assert_eq!(
FetchError::Unknown("clock".into()).to_string(),
"unknown fetcher: clock"
);
assert_eq!(
FetchError::Failed("boom".into()).to_string(),
"fetch failed: boom"
);
}
#[test]
fn cached_fetcher_defaults_and_registry_helpers_surface_contract() {
let mut r = Registry::default();
r.register(Arc::new(Dummy("cached", Safety::Safe)));
let entry = r.get("cached").expect("cached entry must exist");
let cached = r.get_cached("cached").expect("cached fetcher must unwrap");
assert_eq!(entry.name(), "cached");
assert_eq!(entry.description(), "test fixture");
assert_eq!(entry.shapes(), vec![Shape::Text]);
assert_eq!(entry.default_shape(), Shape::Text);
assert!(entry.option_schemas().is_empty());
assert!(matches!(
entry.sample_body(Shape::Text),
Some(Body::Text(_))
));
assert_eq!(cached.default_shape(), Shape::Text);
assert_eq!(cached.refresh_interval(), 60);
assert_eq!(
cached.cache_key(&ctx_with(Some("hi"))),
default_cache_key("cached", &ctx_with(Some("hi")))
);
assert!(cached.option_schemas().is_empty());
assert!(matches!(
cached.sample_body(Shape::Text),
Some(Body::Text(_))
));
assert!(r.get_cached("missing").is_none());
assert_eq!(r.names(), vec!["cached"]);
}
#[test]
fn realtime_fetcher_defaults_and_registry_helpers_surface_contract() {
let mut r = Registry::default();
r.register_realtime(Arc::new(DummyRealtime("rt", Safety::Exec)));
let entry = r.get("rt").expect("realtime entry must exist");
let realtime = r.get_realtime("rt").expect("realtime fetcher must unwrap");
assert_eq!(entry.name(), "rt");
assert_eq!(entry.safety(), Safety::Exec);
assert_eq!(entry.description(), "test fixture");
assert_eq!(entry.shapes(), vec![Shape::Text]);
assert_eq!(entry.default_shape(), Shape::Text);
assert!(entry.option_schemas().is_empty());
assert!(matches!(
entry.sample_body(Shape::Text),
Some(Body::Text(_))
));
assert_eq!(realtime.default_shape(), Shape::Text);
assert!(realtime.option_schemas().is_empty());
assert!(matches!(
realtime.sample_body(Shape::Text),
Some(Body::Text(_))
));
assert!(matches!(
realtime.compute(&FetchContext::default()).body,
Body::Text(_)
));
assert!(r.get_realtime("missing").is_none());
}
#[test]
fn registry_names_and_sorted_reflect_registration_order_independently() {
let mut r = Registry::default();
r.register(Arc::new(Dummy("bravo", Safety::Safe)));
r.register_realtime(Arc::new(DummyRealtime("alpha", Safety::Safe)));
let mut names = r.names();
names.sort_unstable();
assert_eq!(names, vec!["alpha", "bravo"]);
let sorted = r
.sorted()
.into_iter()
.map(|fetcher| fetcher.name().to_string())
.collect::<Vec<_>>();
assert_eq!(sorted, vec!["alpha".to_string(), "bravo".to_string()]);
}
#[test]
fn every_cached_builtin_declares_a_sane_refresh_interval() {
let registry = Registry::with_builtins();
let cached: Vec<_> = registry
.sorted()
.into_iter()
.filter_map(|f| f.as_cached())
.collect();
assert!(!cached.is_empty(), "expected builtin cached fetchers");
for fetcher in cached {
let secs = fetcher.refresh_interval();
assert!(
(1..=60 * 60 * 24 * 7).contains(&secs),
"{} declared refresh_interval() = {secs}s, outside 1s..=1w",
fetcher.name(),
);
}
}
fn ctx_with(format: Option<&str>) -> FetchContext {
FetchContext {
widget_id: "w".into(),
format: format.map(String::from),
timeout: Duration::from_secs(1),
..Default::default()
}
}
#[test]
fn cache_key_is_stable_for_same_inputs() {
let a = default_cache_key("basic_static", &ctx_with(Some("Hi!")));
let b = default_cache_key("basic_static", &ctx_with(Some("Hi!")));
assert_eq!(a, b);
}
#[test]
fn cache_key_differs_across_fetchers() {
let a = default_cache_key("basic_static", &ctx_with(None));
let b = default_cache_key("clock", &ctx_with(None));
assert_ne!(a, b);
}
#[test]
fn cache_key_differs_when_format_differs() {
let a = default_cache_key("basic_static", &ctx_with(Some("Hi!")));
let b = default_cache_key("basic_static", &ctx_with(Some("Bye!")));
assert_ne!(a, b);
}
#[test]
fn cache_key_differs_when_shape_differs() {
let mut a = ctx_with(Some("x"));
let mut b = ctx_with(Some("x"));
a.shape = Some(crate::render::Shape::Text);
b.shape = Some(crate::render::Shape::TextBlock);
assert_ne!(
default_cache_key("basic_static", &a),
default_cache_key("basic_static", &b)
);
}
#[test]
fn cache_key_ignores_widget_id_and_timeout() {
let mut a = ctx_with(Some("x"));
let mut b = ctx_with(Some("x"));
a.widget_id = "greeting_one".into();
b.widget_id = "greeting_two".into();
a.timeout = Duration::from_secs(1);
b.timeout = Duration::from_secs(99);
assert_eq!(
default_cache_key("basic_static", &a),
default_cache_key("basic_static", &b)
);
}
#[test]
fn cache_key_is_prefixed_with_fetcher_name() {
let k = default_cache_key("basic_static", &ctx_with(None));
assert!(k.starts_with("basic_static-"));
}
#[test]
fn unknown_fetcher_placeholder_mentions_name_and_upgrade_hint() {
let p = unknown_fetcher_placeholder("system_battery");
let Body::Error(err) = &p.body else {
panic!("expected Body::Error, got {:?}", p.body);
};
assert_eq!(err.kind, ErrorKind::UnknownFetcher);
assert!(err.message.contains("system_battery"));
assert!(err.message.starts_with("⚠"));
assert!(err.detail.as_deref().is_some_and(|d| d.contains("upgrade")));
assert_eq!(p.status, Some(Status::Error));
}
#[test]
fn shape_mismatch_placeholder_carries_offending_pair_in_message() {
let err = ShapeMismatch {
fetcher: "static_text".into(),
requested: Shape::Bars,
};
let p = shape_mismatch_placeholder(&err);
let Body::Error(e) = &p.body else {
panic!("expected Body::Error, got {:?}", p.body);
};
assert_eq!(e.kind, ErrorKind::ShapeMismatch);
assert!(e.message.contains("static_text"));
assert!(e.message.contains("bars"));
assert!(
e.detail.as_deref().is_some_and(|d| d.contains("`render`")),
"detail should point at the render config: {:?}",
e.detail,
);
}
#[test]
fn error_placeholder_prefixes_warn_glyph_and_points_at_logs() {
let p = error_placeholder("deariary 429: rate limited");
let Body::Error(err) = &p.body else {
panic!("expected Body::Error, got {:?}", p.body);
};
assert_eq!(err.kind, ErrorKind::Fetch);
assert!(err.message.starts_with("⚠"));
assert!(err.message.contains("rate limited"));
assert!(err.detail.as_deref().is_some_and(|d| d.contains("logs")));
}
#[test]
fn timeout_placeholder_uses_clock_glyph_and_points_at_logs() {
let p = timeout_placeholder();
let Body::Error(err) = &p.body else {
panic!("expected Body::Error, got {:?}", p.body);
};
assert_eq!(err.kind, ErrorKind::Timeout);
assert!(err.message.starts_with("⏱"));
assert!(err.detail.as_deref().is_some_and(|d| d.contains("logs")));
}
#[test]
fn error_payloads_serialize_round_trip_through_serde() {
let original = error_placeholder("deariary 429");
let json = serde_json::to_string(&original).unwrap();
let back: Payload = serde_json::from_str(&json).unwrap();
assert_eq!(original, back);
assert!(matches!(back.body, Body::Error(_)));
}
}