use async_trait::async_trait;
use chrono::{Datelike, NaiveDate, Utc};
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::payload::{
Body, LinkedLine, LinkedTextBlockData, Payload, TextBlockData, TextData, TimelineData,
TimelineEvent,
};
use crate::render::Shape;
use crate::samples;
use super::client::{DEFAULT_LANG, PageSummary, get, rest_api_base};
const DEFAULT_LIMIT: u32 = 5;
const MIN_LIMIT: u32 = 1;
const MAX_LIMIT: u32 = 30;
const SHAPES: &[Shape] = &[
Shape::LinkedTextBlock,
Shape::TextBlock,
Shape::Text,
Shape::Timeline,
];
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "kind",
type_hint: "\"selected\" | \"events\" | \"births\" | \"deaths\" | \"holidays\" | \"all\"",
required: false,
default: Some("\"selected\""),
description: "Which slice of the on-this-day feed to fetch. `selected` is the curated highlights.",
},
OptionSchema {
name: "limit",
type_hint: "integer (1..=30)",
required: false,
default: Some("5"),
description: "Maximum number of events to display.",
},
OptionSchema {
name: "lang",
type_hint: "string (Wikipedia language code, e.g. `\"en\"` / `\"ja\"`)",
required: false,
default: Some("\"en\""),
description: "Wikipedia language edition to query.",
},
];
pub struct WikipediaOnThisDayFetcher;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Options {
#[serde(default)]
pub kind: Option<Kind>,
#[serde(default)]
pub limit: Option<u32>,
#[serde(default)]
pub lang: Option<String>,
}
#[derive(Debug, Default, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
#[default]
Selected,
Events,
Births,
Deaths,
Holidays,
All,
}
impl Kind {
fn endpoint(self) -> &'static str {
match self {
Self::Selected => "selected",
Self::Events => "events",
Self::Births => "births",
Self::Deaths => "deaths",
Self::Holidays => "holidays",
Self::All => "all",
}
}
}
#[async_trait]
impl Fetcher for WikipediaOnThisDayFetcher {
fn name(&self) -> &str {
"wikipedia_on_this_day"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Events that happened on today's calendar date in history, sourced from Wikipedia's on-this-day feed (selected highlights, events, births, deaths, holidays, or all). Use `wikipedia_featured` for today's featured article or `wikipedia_random` for an arbitrary page."
}
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> {
Some(match shape {
Shape::LinkedTextBlock => samples::linked_text_block(&[
(
"1969 — Apollo 11 lands on the Moon",
Some("https://en.wikipedia.org/wiki/Apollo_11"),
),
(
"1492 — Columbus departs Palos de la Frontera",
Some("https://en.wikipedia.org/wiki/Christopher_Columbus"),
),
(
"1989 — Tim Berners-Lee proposes the World Wide Web",
Some("https://en.wikipedia.org/wiki/World_Wide_Web"),
),
]),
Shape::TextBlock => samples::text_block(&[
"1969 — Apollo 11 lands on the Moon",
"1492 — Columbus departs Palos de la Frontera",
"1989 — Tim Berners-Lee proposes the World Wide Web",
]),
Shape::Text => samples::text("1969 — Apollo 11 lands on the Moon"),
Shape::Timeline => samples::timeline(&[
(
-86_400 * 365 * 2, "1969 — Apollo 11 lands on the Moon",
None,
),
(
-86_400 * 365 * 4,
"1492 — Columbus departs Palos de la Frontera",
None,
),
]),
_ => return None,
})
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let opts: Options = parse_options(ctx.options.as_ref()).map_err(FetchError::Failed)?;
let kind = opts.kind.unwrap_or_default();
let lang = opts.lang.as_deref().unwrap_or(DEFAULT_LANG);
let limit = opts
.limit
.unwrap_or(DEFAULT_LIMIT)
.clamp(MIN_LIMIT, MAX_LIMIT) as usize;
let events = fetch_events(lang, kind).await?;
let shape = ctx.shape.unwrap_or(Shape::LinkedTextBlock);
Ok(payload(render_body(&events, shape, limit)))
}
}
async fn fetch_events(lang: &str, kind: Kind) -> Result<Vec<EventEntry>, FetchError> {
let now = Utc::now();
let (month, day) = (now.month(), now.day());
let url = format!(
"{}/feed/onthisday/{}/{:02}/{:02}",
rest_api_base(lang),
kind.endpoint(),
month,
day
);
let response: OnThisDayResponse = get(&url).await?;
Ok(response.into_events(kind, now.year(), month, day))
}
#[derive(Debug, Default, Deserialize)]
struct OnThisDayResponse {
#[serde(default)]
selected: Vec<RawEvent>,
#[serde(default)]
events: Vec<RawEvent>,
#[serde(default)]
births: Vec<RawEvent>,
#[serde(default)]
deaths: Vec<RawEvent>,
#[serde(default)]
holidays: Vec<RawEvent>,
}
impl OnThisDayResponse {
fn into_events(self, kind: Kind, today_year: i32, month: u32, day: u32) -> Vec<EventEntry> {
let raw = match kind {
Kind::Selected => self.selected,
Kind::Events => self.events,
Kind::Births => self.births,
Kind::Deaths => self.deaths,
Kind::Holidays => self.holidays,
Kind::All => {
let mut all = self.selected;
all.extend(self.events);
all.extend(self.births);
all.extend(self.deaths);
all.extend(self.holidays);
all
}
};
raw.into_iter()
.map(|r| EventEntry::from_raw(r, today_year, month, day))
.collect()
}
}
#[derive(Debug, Deserialize)]
struct RawEvent {
#[serde(default)]
text: String,
#[serde(default)]
year: Option<i32>,
#[serde(default)]
pages: Vec<PageSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct EventEntry {
label: String,
url: Option<String>,
timestamp: i64,
}
impl EventEntry {
fn from_raw(raw: RawEvent, today_year: i32, month: u32, day: u32) -> Self {
let label = match raw.year {
Some(year) => format!("{year} — {}", raw.text),
None => raw.text,
};
let url = raw.pages.first().and_then(|p| p.url());
let year = raw.year.unwrap_or(today_year);
Self {
label,
url,
timestamp: anniversary_timestamp(year, month, day),
}
}
}
fn anniversary_timestamp(year: i32, month: u32, day: u32) -> i64 {
let date = NaiveDate::from_ymd_opt(year, month, day)
.or_else(|| NaiveDate::from_ymd_opt(year, month, 1))
.or_else(|| NaiveDate::from_ymd_opt(year, 1, 1));
date.and_then(|d| d.and_hms_opt(12, 0, 0))
.map(|dt| dt.and_utc().timestamp())
.unwrap_or(0)
}
fn render_body(events: &[EventEntry], shape: Shape, limit: usize) -> Body {
match shape {
Shape::Text => Body::Text(TextData {
value: events.first().map(|e| e.label.clone()).unwrap_or_default(),
}),
Shape::TextBlock => Body::TextBlock(TextBlockData {
lines: events.iter().take(limit).map(|e| e.label.clone()).collect(),
}),
Shape::Timeline => Body::Timeline(TimelineData {
events: events
.iter()
.take(limit)
.map(|e| TimelineEvent {
timestamp: e.timestamp,
title: e.label.clone(),
detail: None,
status: None,
})
.collect(),
}),
_ => Body::LinkedTextBlock(LinkedTextBlockData {
items: events
.iter()
.take(limit)
.map(|e| LinkedLine {
text: e.label.clone(),
url: e.url.clone(),
})
.collect(),
}),
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::fetcher::wikipedia::client::{ContentUrls, UrlsByPlatform};
fn page(title: &str, url: &str) -> PageSummary {
PageSummary {
title: title.into(),
extract: None,
content_urls: Some(ContentUrls {
desktop: Some(UrlsByPlatform { page: url.into() }),
mobile: None,
}),
thumbnail: None,
}
}
fn raw_event(year: Option<i32>, text: &str, pages: Vec<PageSummary>) -> RawEvent {
RawEvent {
text: text.into(),
year,
pages,
}
}
fn ctx(options: Option<&str>, shape: Option<Shape>, format: Option<&str>) -> FetchContext {
FetchContext {
widget_id: "wiki-on-this-day".into(),
format: format.map(str::to_string),
timeout: Duration::from_secs(1),
file_format: None,
shape,
options: options.map(|raw| toml::from_str(raw).unwrap()),
timezone: None,
locale: None,
}
}
#[test]
fn options_default_to_none_for_every_field() {
let opts = Options::default();
assert!(opts.kind.is_none());
assert!(opts.limit.is_none());
assert!(opts.lang.is_none());
}
#[test]
fn options_deserialize_kind_lang_limit() {
let raw: toml::Value =
toml::from_str("kind = \"births\"\nlimit = 3\nlang = \"ja\"").unwrap();
let opts: Options = raw.try_into().unwrap();
assert_eq!(opts.kind, Some(Kind::Births));
assert_eq!(opts.limit, Some(3));
assert_eq!(opts.lang.as_deref(), Some("ja"));
}
#[test]
fn options_reject_unknown_keys() {
let raw: toml::Value = toml::from_str("kind = \"selected\"\nbogus = true").unwrap();
assert!(raw.try_into::<Options>().is_err());
}
#[test]
fn kind_endpoint_covers_every_variant() {
assert_eq!(Kind::Selected.endpoint(), "selected");
assert_eq!(Kind::Events.endpoint(), "events");
assert_eq!(Kind::Births.endpoint(), "births");
assert_eq!(Kind::Deaths.endpoint(), "deaths");
assert_eq!(Kind::Holidays.endpoint(), "holidays");
assert_eq!(Kind::All.endpoint(), "all");
}
#[test]
fn fetcher_contract_cache_key_and_samples_cover_supported_shapes() {
let fetcher = WikipediaOnThisDayFetcher;
assert_eq!(fetcher.name(), "wikipedia_on_this_day");
assert_eq!(fetcher.safety(), Safety::Safe);
assert_eq!(fetcher.default_shape(), Shape::LinkedTextBlock);
assert_eq!(fetcher.shapes(), SHAPES);
assert_eq!(fetcher.option_schemas().len(), 3);
assert_eq!(fetcher.option_schemas()[0].name, "kind");
assert_eq!(fetcher.option_schemas()[1].name, "limit");
assert_eq!(fetcher.option_schemas()[2].name, "lang");
assert!(fetcher.description().contains("featured article"));
let linked = fetcher.sample_body(Shape::LinkedTextBlock);
assert!(matches!(linked, Some(Body::LinkedTextBlock(_))));
if let Some(Body::LinkedTextBlock(linked)) = linked {
assert_eq!(linked.items.len(), 3);
assert!(linked.items[0].url.is_some());
}
let text_block = fetcher.sample_body(Shape::TextBlock);
assert!(matches!(text_block, Some(Body::TextBlock(_))));
if let Some(Body::TextBlock(text_block)) = text_block {
assert_eq!(text_block.lines.len(), 3);
assert!(text_block.lines[0].contains("Apollo 11"));
}
let text = fetcher.sample_body(Shape::Text);
assert!(matches!(text, Some(Body::Text(_))));
if let Some(Body::Text(text)) = text {
assert!(text.value.contains("Apollo 11"));
}
let timeline = fetcher.sample_body(Shape::Timeline);
assert!(matches!(timeline, Some(Body::Timeline(_))));
if let Some(Body::Timeline(timeline)) = timeline {
assert_eq!(timeline.events.len(), 2);
assert!(timeline.events[0].title.contains("Apollo 11"));
}
assert!(fetcher.sample_body(Shape::Entries).is_none());
let base = fetcher.cache_key(&ctx(
Some("kind = \"selected\"\nlimit = 5\nlang = \"en\""),
Some(Shape::LinkedTextBlock),
Some("plain"),
));
let same = fetcher.cache_key(&ctx(
Some("kind = \"selected\"\nlimit = 5\nlang = \"en\""),
Some(Shape::LinkedTextBlock),
Some("plain"),
));
let different_kind = fetcher.cache_key(&ctx(
Some("kind = \"events\"\nlimit = 5\nlang = \"en\""),
Some(Shape::LinkedTextBlock),
Some("plain"),
));
let different_shape = fetcher.cache_key(&ctx(
Some("kind = \"selected\"\nlimit = 5\nlang = \"en\""),
Some(Shape::Timeline),
Some("plain"),
));
let different_format = fetcher.cache_key(&ctx(
Some("kind = \"selected\"\nlimit = 5\nlang = \"en\""),
Some(Shape::LinkedTextBlock),
Some("json"),
));
assert_eq!(base, same);
assert_ne!(base, different_kind);
assert_ne!(base, different_shape);
assert_ne!(base, different_format);
}
#[test]
fn event_entry_prefixes_year_when_present() {
let e = EventEntry::from_raw(
raw_event(Some(1969), "Apollo 11 lands.", vec![]),
2026,
4,
27,
);
assert_eq!(e.label, "1969 — Apollo 11 lands.");
assert!(e.url.is_none());
}
#[test]
fn event_entry_omits_year_dash_when_year_missing() {
let e = EventEntry::from_raw(raw_event(None, "Independence Day", vec![]), 2026, 4, 27);
assert_eq!(e.label, "Independence Day");
}
#[test]
fn event_entry_url_uses_first_page() {
let e = EventEntry::from_raw(
raw_event(
Some(1969),
"Apollo 11.",
vec![
page("Apollo 11", "https://en.wikipedia.org/wiki/Apollo_11"),
page("Moon", "https://en.wikipedia.org/wiki/Moon"),
],
),
2026,
4,
27,
);
assert_eq!(
e.url.as_deref(),
Some("https://en.wikipedia.org/wiki/Apollo_11")
);
}
#[test]
fn event_entry_timestamp_uses_event_year_with_today_month_day() {
let e = EventEntry::from_raw(raw_event(Some(1969), "Apollo 11.", vec![]), 2026, 7, 20);
let date = NaiveDate::from_ymd_opt(1969, 7, 20)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap()
.and_utc()
.timestamp();
assert_eq!(e.timestamp, date);
}
#[test]
fn event_entry_timestamp_falls_back_to_today_year_when_missing() {
let e = EventEntry::from_raw(raw_event(None, "Independence Day", vec![]), 2026, 7, 4);
let date = NaiveDate::from_ymd_opt(2026, 7, 4)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap()
.and_utc()
.timestamp();
assert_eq!(e.timestamp, date);
}
fn entry(label: &str, url: Option<&str>) -> EventEntry {
EventEntry {
label: label.into(),
url: url.map(String::from),
timestamp: 0,
}
}
#[test]
fn linked_text_block_default_carries_per_event_url() {
let events = vec![
entry("1969 — A", Some("https://en.wikipedia.org/A")),
entry("1066 — B", None),
];
let body = render_body(&events, Shape::LinkedTextBlock, 5);
assert!(matches!(body, Body::LinkedTextBlock(_)));
if let Body::LinkedTextBlock(b) = body {
assert_eq!(b.items.len(), 2);
assert_eq!(b.items[0].text, "1969 — A");
assert_eq!(
b.items[0].url.as_deref(),
Some("https://en.wikipedia.org/A")
);
assert!(b.items[1].url.is_none());
}
}
#[test]
fn text_block_drops_links_keeps_label_lines() {
let events = vec![
entry("1969 — A", Some("https://en.wikipedia.org/A")),
entry("1066 — B", None),
];
let body = render_body(&events, Shape::TextBlock, 5);
assert!(matches!(body, Body::TextBlock(_)));
if let Body::TextBlock(t) = body {
assert_eq!(t.lines, vec!["1969 — A", "1066 — B"]);
}
}
#[test]
fn text_shape_emits_first_event_label() {
let events = vec![entry("1969 — A", None), entry("1066 — B", None)];
let body = render_body(&events, Shape::Text, 5);
assert!(matches!(body, Body::Text(_)));
if let Body::Text(t) = body {
assert_eq!(t.value, "1969 — A");
}
}
#[test]
fn text_shape_is_empty_when_no_events() {
let body = render_body(&[], Shape::Text, 5);
assert!(matches!(body, Body::Text(_)));
if let Body::Text(t) = body {
assert!(t.value.is_empty());
}
}
#[test]
fn limit_caps_emitted_rows() {
let events: Vec<_> = (0..10).map(|i| entry(&format!("e{i}"), None)).collect();
let body = render_body(&events, Shape::LinkedTextBlock, 3);
assert!(matches!(body, Body::LinkedTextBlock(_)));
if let Body::LinkedTextBlock(b) = body {
assert_eq!(b.items.len(), 3);
}
}
#[test]
fn empty_events_produce_empty_linked_text_block() {
let body = render_body(&[], Shape::LinkedTextBlock, 5);
assert!(matches!(body, Body::LinkedTextBlock(_)));
if let Body::LinkedTextBlock(b) = body {
assert!(b.items.is_empty());
}
}
#[test]
fn into_events_kind_selected_returns_only_selected_slice() {
let resp = OnThisDayResponse {
selected: vec![raw_event(Some(1), "s", vec![])],
events: vec![raw_event(Some(2), "e", vec![])],
..Default::default()
};
let events = resp.into_events(Kind::Selected, 2026, 4, 27);
assert_eq!(events.len(), 1);
assert!(events[0].label.contains("s"));
}
#[test]
fn into_events_kind_all_concatenates_every_slice() {
let resp = OnThisDayResponse {
selected: vec![raw_event(Some(1), "s", vec![])],
events: vec![raw_event(Some(2), "e", vec![])],
births: vec![raw_event(Some(3), "b", vec![])],
deaths: vec![raw_event(Some(4), "d", vec![])],
holidays: vec![raw_event(None, "h", vec![])],
};
let events = resp.into_events(Kind::All, 2026, 4, 27);
assert_eq!(events.len(), 5);
}
#[test]
fn into_events_covers_non_selected_kinds() {
let response = |kind| {
OnThisDayResponse {
selected: vec![raw_event(Some(1), "selected", vec![])],
events: vec![raw_event(Some(2), "event", vec![])],
births: vec![raw_event(Some(3), "birth", vec![])],
deaths: vec![raw_event(Some(4), "death", vec![])],
holidays: vec![raw_event(None, "holiday", vec![])],
}
.into_events(kind, 2026, 4, 27)
};
assert_eq!(response(Kind::Events)[0].label, "2 — event");
assert_eq!(response(Kind::Births)[0].label, "3 — birth");
assert_eq!(response(Kind::Deaths)[0].label, "4 — death");
assert_eq!(response(Kind::Holidays)[0].label, "holiday");
}
#[test]
fn limit_clamps_extremes() {
assert_eq!(0u32.clamp(MIN_LIMIT, MAX_LIMIT), MIN_LIMIT);
assert_eq!(999u32.clamp(MIN_LIMIT, MAX_LIMIT), MAX_LIMIT);
assert_eq!(DEFAULT_LIMIT.clamp(MIN_LIMIT, MAX_LIMIT), DEFAULT_LIMIT);
}
#[test]
fn response_deserializes_realistic_payload() {
let raw = r#"{
"selected": [
{ "year": 1969, "text": "Apollo 11.", "pages": [
{ "title": "Apollo 11",
"content_urls": { "desktop": { "page": "https://en.wikipedia.org/wiki/Apollo_11" } } }
]}
],
"holidays": [
{ "text": "Independence Day", "pages": [] }
]
}"#;
let r: OnThisDayResponse = serde_json::from_str(raw).unwrap();
let selected = r.into_events(Kind::Selected, 2026, 4, 27);
assert_eq!(selected[0].label, "1969 — Apollo 11.");
assert_eq!(
selected[0].url.as_deref(),
Some("https://en.wikipedia.org/wiki/Apollo_11")
);
}
#[test]
fn timeline_body_carries_anniversary_timestamps() {
let events = vec![
EventEntry {
label: "1969 — A".into(),
url: None,
timestamp: 12345,
},
EventEntry {
label: "1066 — B".into(),
url: None,
timestamp: 67890,
},
];
let body = render_body(&events, Shape::Timeline, 5);
assert!(matches!(body, Body::Timeline(_)));
if let Body::Timeline(t) = body {
assert_eq!(t.events.len(), 2);
assert_eq!(t.events[0].timestamp, 12345);
assert_eq!(t.events[0].title, "1969 — A");
assert_eq!(t.events[1].timestamp, 67890);
}
}
#[tokio::test]
async fn fetch_rejects_unknown_options_before_network() {
let fetcher = WikipediaOnThisDayFetcher;
let err = fetcher
.fetch(&ctx(Some("bogus = true"), Some(Shape::Text), Some("plain")))
.await
.unwrap_err();
assert!(matches!(&err, FetchError::Failed(message) if message.contains("unknown field")));
}
#[tokio::test]
async fn fetch_surfaces_request_error_for_invalid_lang() {
let fetcher = WikipediaOnThisDayFetcher;
let err = fetcher
.fetch(&ctx(
Some("kind = \"births\"\nlimit = 999\nlang = \"bad lang\""),
Some(Shape::Text),
Some("plain"),
))
.await
.unwrap_err();
assert!(matches!(
&err,
FetchError::Failed(message) if message.contains("wikipedia request failed")
));
}
}