#![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, EntriesData, Entry, LinkedLine, LinkedTextBlockData, MarkdownTextBlockData, Payload,
TextBlockData, TextData,
};
use crate::render::Shape;
use crate::samples;
pub mod calendar;
pub mod clock;
pub mod code;
pub mod git;
pub mod github;
pub mod hackernews;
pub mod random_fortune;
pub mod random_quote;
pub mod read_store;
pub mod reddit;
pub mod rss;
pub mod static_text;
pub mod system;
pub mod todoist;
pub mod weather;
pub mod wikipedia;
#[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>,
}
#[async_trait]
pub trait Fetcher: 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 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,
}
pub fn placeholder_body(shape: Shape, lines: &[&str]) -> Body {
match shape {
Shape::Text => Body::Text(TextData {
value: lines.join(" — "),
}),
Shape::TextBlock => Body::TextBlock(TextBlockData {
lines: lines.iter().map(|s| (*s).to_string()).collect(),
}),
Shape::MarkdownTextBlock => Body::MarkdownTextBlock(MarkdownTextBlockData {
value: lines.join("\n\n"),
}),
Shape::LinkedTextBlock => Body::LinkedTextBlock(LinkedTextBlockData {
items: lines
.iter()
.map(|s| LinkedLine {
text: (*s).to_string(),
url: None,
})
.collect(),
}),
Shape::Entries => Body::Entries(EntriesData {
items: lines
.iter()
.map(|s| Entry {
key: (*s).to_string(),
value: None,
status: None,
})
.collect(),
}),
_ => Body::TextBlock(TextBlockData {
lines: lines.iter().map(|s| (*s).to_string()).collect(),
}),
}
}
pub fn shape_mismatch_placeholder(err: &ShapeMismatch) -> Payload {
let line1 = format!("⚠ {} can't emit {}", err.fetcher, err.requested.as_str());
let lines = [line1.as_str(), "check `render` in config"];
Payload {
icon: None,
status: None,
format: None,
body: placeholder_body(err.requested, &lines),
}
}
pub fn unknown_fetcher_placeholder(shape: Shape, name: &str) -> Payload {
let line1 = format!("⚠ unknown fetcher: {name}");
Payload {
icon: None,
status: None,
format: None,
body: placeholder_body(shape, &[line1.as_str(), "upgrade splashboard?"]),
}
}
pub fn error_placeholder(shape: Shape, msg: &str) -> Payload {
let line1 = format!("⚠ {msg}");
Payload {
icon: None,
status: None,
format: None,
body: placeholder_body(shape, &[line1.as_str(), "see $HOME/.splashboard/logs"]),
}
}
pub fn timeout_placeholder(shape: Shape) -> Payload {
Payload {
icon: None,
status: None,
format: None,
body: placeholder_body(shape, &["⏱ timed out", "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(weather::WeatherFetcher));
for f in calendar::fetchers() {
r.register(f);
}
for f in hackernews::fetchers() {
r.register(f);
}
for f in todoist::fetchers() {
r.register(f);
}
for f in reddit::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 git::fetchers() {
r.register(f);
}
for f in github::fetchers() {
r.register(f);
}
for f in code::fetchers() {
r.register(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 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_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);
}
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(Shape::TextBlock, "system_battery");
match p.body {
Body::TextBlock(t) => {
assert!(
t.lines[0].contains("system_battery"),
"lines: {:?}",
t.lines
);
assert!(t.lines[0].starts_with("⚠"), "lines: {:?}", t.lines);
assert!(
t.lines.iter().any(|l| l.contains("upgrade")),
"expected upgrade hint: {:?}",
t.lines
);
}
other => panic!("expected TextBlock body, got {other:?}"),
}
}
#[test]
fn placeholder_body_adapts_to_shape() {
let body = placeholder_body(Shape::TextBlock, &["hello", "world"]);
let Body::TextBlock(t) = body else {
panic!("expected text block");
};
assert_eq!(t.lines, vec!["hello".to_string(), "world".to_string()]);
let body = placeholder_body(Shape::LinkedTextBlock, &["hello", "world"]);
let Body::LinkedTextBlock(b) = body else {
panic!("expected linked text block");
};
assert_eq!(b.items.len(), 2);
assert!(b.items.iter().all(|i| i.url.is_none()));
let body = placeholder_body(Shape::Text, &["hello", "world"]);
let Body::Text(t) = body else {
panic!("expected text");
};
assert!(t.value.contains("hello") && t.value.contains("world"));
let body = placeholder_body(Shape::Entries, &["hello"]);
let Body::Entries(e) = body else {
panic!("expected entries");
};
assert_eq!(e.items[0].key, "hello");
assert!(e.items[0].value.is_none());
let body = placeholder_body(Shape::Ratio, &["x"]);
assert!(matches!(body, Body::TextBlock(_)));
}
#[test]
fn requires_trust_placeholder_via_fetcher_module_emits_link_shape() {
let body = placeholder_body(
Shape::LinkedTextBlock,
&["🔒 requires trust", "run `splashboard trust`"],
);
assert!(matches!(body, Body::LinkedTextBlock(_)));
}
}