use std::sync::OnceLock;
use std::time::Duration;
use async_trait::async_trait;
use chrono::{Datelike, NaiveDate, Utc};
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::payload::{Body, CalendarData, Payload, TextBlockData, TextData};
use crate::render::Shape;
const API_BASE: &str = "https://date.nager.at/api/v3/PublicHolidays";
const USER_AGENT: &str = concat!("splashboard/", env!("CARGO_PKG_VERSION"));
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_LIMIT: u32 = 5;
const MIN_LIMIT: u32 = 1;
const MAX_LIMIT: u32 = 30;
const SHAPES: &[Shape] = &[Shape::Calendar, Shape::Text, Shape::TextBlock];
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "country",
type_hint: "string (ISO 3166-1 alpha-2, e.g. \"JP\" / \"US\")",
required: true,
default: None,
description: "Country whose public holidays to fetch.",
},
OptionSchema {
name: "language",
type_hint: "\"local\" | \"english\"",
required: false,
default: Some("\"local\""),
description: "Display-name source. `local` uses the country's localized name; `english` uses the English label.",
},
OptionSchema {
name: "limit",
type_hint: "integer (1..=30)",
required: false,
default: Some("5"),
description: "Maximum number of upcoming holidays to list (TextBlock shape only).",
},
];
pub struct CalendarHolidaysFetcher;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Options {
pub country: Option<String>,
#[serde(default)]
pub language: Option<Language>,
#[serde(default)]
pub limit: Option<u32>,
}
#[derive(Debug, Default, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Language {
#[default]
Local,
English,
}
#[async_trait]
impl Fetcher for CalendarHolidaysFetcher {
fn name(&self) -> &str {
"calendar_holidays"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Public holidays for a country (ISO 3166-1 alpha-2) via date.nager.at. `Calendar` highlights this month's holiday dates, `Text` shows today's holiday name when one matches, and `TextBlock` lists the next few upcoming holidays."
}
fn refresh_interval(&self) -> u64 {
60 * 60 * 24
}
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::Calendar => sample_calendar(),
Shape::Text => Body::Text(TextData {
value: "🎌 Showa Day".into(),
}),
Shape::TextBlock => Body::TextBlock(TextBlockData {
lines: vec![
"04/29 — Showa Day".into(),
"05/03 — Constitution Day".into(),
"05/04 — Greenery Day".into(),
"05/05 — Children's Day".into(),
],
}),
_ => 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 country = require_country(opts.country.as_deref())?;
let language = opts.language.unwrap_or_default();
let limit = opts
.limit
.unwrap_or(DEFAULT_LIMIT)
.clamp(MIN_LIMIT, MAX_LIMIT) as usize;
let today = Utc::now().date_naive();
let holidays = fetch_year(today.year(), &country).await?;
let shape = ctx.shape.unwrap_or(Shape::Calendar);
Ok(payload(render_body(
&holidays, shape, today, language, limit,
)))
}
}
fn require_country(raw: Option<&str>) -> Result<String, FetchError> {
let raw = raw.filter(|s| !s.is_empty()).ok_or_else(|| {
FetchError::Failed("calendar_holidays requires `country` (ISO 3166-1 alpha-2)".into())
})?;
encode_country(raw).ok_or_else(|| {
FetchError::Failed(format!(
"invalid country code (need ISO 3166-1 alpha-2): {raw:?}"
))
})
}
fn encode_country(raw: &str) -> Option<String> {
let cleaned: String = raw
.chars()
.filter(|c| c.is_ascii_alphabetic())
.collect::<String>()
.to_ascii_uppercase();
(cleaned.len() == 2).then_some(cleaned)
}
async fn fetch_year(year: i32, country: &str) -> Result<Vec<Holiday>, FetchError> {
let url = format!("{API_BASE}/{year}/{country}");
let res = http()
.get(&url)
.send()
.await
.map_err(|e| FetchError::Failed(format!("calendar_holidays request failed: {e}")))?;
let status = res.status();
if !status.is_success() {
return Err(FetchError::Failed(format!("calendar_holidays {status}")));
}
res.json()
.await
.map_err(|e| FetchError::Failed(format!("calendar_holidays json parse: {e}")))
}
#[derive(Debug, Clone, Deserialize)]
struct Holiday {
date: String,
#[serde(rename = "localName")]
local_name: String,
name: String,
}
impl Holiday {
fn parse_date(&self) -> Option<NaiveDate> {
NaiveDate::parse_from_str(&self.date, "%Y-%m-%d").ok()
}
fn label(&self, language: Language) -> &str {
match language {
Language::Local => &self.local_name,
Language::English => &self.name,
}
}
}
fn render_body(
holidays: &[Holiday],
shape: Shape,
today: NaiveDate,
language: Language,
limit: usize,
) -> Body {
match shape {
Shape::Text => Body::Text(TextData {
value: today_label(holidays, today, language).unwrap_or_default(),
}),
Shape::TextBlock => Body::TextBlock(TextBlockData {
lines: upcoming_lines(holidays, today, language, limit),
}),
_ => Body::Calendar(month_calendar(holidays, today)),
}
}
fn today_label(holidays: &[Holiday], today: NaiveDate, language: Language) -> Option<String> {
holidays
.iter()
.find(|h| h.parse_date() == Some(today))
.map(|h| format!("🎌 {}", h.label(language)))
}
fn upcoming_lines(
holidays: &[Holiday],
today: NaiveDate,
language: Language,
limit: usize,
) -> Vec<String> {
let mut dated: Vec<(NaiveDate, &Holiday)> = holidays
.iter()
.filter_map(|h| Some((h.parse_date()?, h)))
.filter(|(d, _)| *d >= today)
.collect();
dated.sort_by_key(|(d, _)| *d);
dated
.into_iter()
.take(limit)
.map(|(d, h)| format!("{:02}/{:02} — {}", d.month(), d.day(), h.label(language)))
.collect()
}
fn month_calendar(holidays: &[Holiday], today: NaiveDate) -> CalendarData {
let events: Vec<u8> = holidays
.iter()
.filter_map(|h| h.parse_date())
.filter(|d| d.year() == today.year() && d.month() == today.month())
.map(|d| d.day() as u8)
.collect();
CalendarData {
year: today.year(),
month: today.month() as u8,
day: Some(today.day() as u8),
events,
}
}
fn sample_calendar() -> Body {
Body::Calendar(CalendarData {
year: 2026,
month: 4,
day: Some(29),
events: vec![29],
})
}
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")
})
}
#[cfg(test)]
mod tests {
use std::future::Future;
use std::time::Duration as StdDuration;
use super::*;
fn h(date: &str, local: &str, name: &str) -> Holiday {
Holiday {
date: date.into(),
local_name: local.into(),
name: name.into(),
}
}
fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn ctx(options: Option<&str>, shape: Option<Shape>) -> FetchContext {
FetchContext {
widget_id: "calendar-holidays".into(),
format: Some("compact".into()),
timeout: StdDuration::from_secs(1),
file_format: None,
shape,
options: options.map(|raw| toml::from_str(raw).unwrap()),
timezone: None,
locale: None,
}
}
fn run_async<T>(future: impl Future<Output = T>) -> T {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(future)
}
#[test]
fn options_default_to_none_for_every_field() {
let opts = Options::default();
assert!(opts.country.is_none());
assert!(opts.language.is_none());
assert!(opts.limit.is_none());
}
#[test]
fn options_deserialize_country_language_limit() {
let raw: toml::Value =
toml::from_str("country = \"JP\"\nlanguage = \"english\"\nlimit = 3").unwrap();
let opts: Options = raw.try_into().unwrap();
assert_eq!(opts.country.as_deref(), Some("JP"));
assert_eq!(opts.language, Some(Language::English));
assert_eq!(opts.limit, Some(3));
}
#[test]
fn options_reject_unknown_keys() {
let raw: toml::Value = toml::from_str("country = \"JP\"\nbogus = true").unwrap();
assert!(raw.try_into::<Options>().is_err());
}
#[test]
fn require_country_errors_when_missing_or_blank() {
assert!(require_country(None).is_err());
assert!(require_country(Some("")).is_err());
}
#[test]
fn require_country_uppercases_and_validates_length() {
assert_eq!(require_country(Some("jp")).unwrap(), "JP");
assert!(require_country(Some("Japan")).is_err());
assert!(require_country(Some("J")).is_err());
assert!(require_country(Some("J1")).is_err());
}
#[test]
fn encode_country_strips_non_letters_then_validates_length() {
assert_eq!(encode_country("us").as_deref(), Some("US"));
assert_eq!(encode_country("U.S").as_deref(), Some("US"));
assert_eq!(encode_country("USA").as_deref(), None);
assert_eq!(encode_country("").as_deref(), None);
assert_eq!(encode_country("12").as_deref(), None);
}
#[test]
fn holiday_parse_date_reads_iso_format() {
assert_eq!(
h("2026-04-29", "x", "y").parse_date(),
Some(ymd(2026, 4, 29))
);
assert!(h("not-a-date", "x", "y").parse_date().is_none());
}
#[test]
fn holiday_label_switches_on_language() {
let entry = h("2026-04-29", "昭和の日", "Showa Day");
assert_eq!(entry.label(Language::Local), "昭和の日");
assert_eq!(entry.label(Language::English), "Showa Day");
}
#[test]
fn today_label_emits_emoji_prefix_when_today_matches() {
let list = vec![h("2026-04-29", "昭和の日", "Showa Day")];
assert_eq!(
today_label(&list, ymd(2026, 4, 29), Language::Local),
Some("🎌 昭和の日".into())
);
}
#[test]
fn today_label_returns_none_when_no_match() {
let list = vec![h("2026-04-29", "昭和の日", "Showa Day")];
assert!(today_label(&list, ymd(2026, 4, 30), Language::Local).is_none());
}
#[test]
fn upcoming_lines_includes_today_and_filters_past() {
let list = vec![
h("2026-04-29", "Showa", "Showa Day"),
h("2026-04-01", "April Fool", "April Fool"),
h("2026-05-03", "Constitution", "Constitution Day"),
];
let lines = upcoming_lines(&list, ymd(2026, 4, 29), Language::English, 5);
assert_eq!(lines, vec!["04/29 — Showa Day", "05/03 — Constitution Day"]);
}
#[test]
fn upcoming_lines_sorts_chronologically_and_caps_to_limit() {
let list = vec![
h("2026-12-23", "A", "A"),
h("2026-05-03", "B", "B"),
h("2026-07-19", "C", "C"),
];
let lines = upcoming_lines(&list, ymd(2026, 5, 1), Language::English, 2);
assert_eq!(lines, vec!["05/03 — B", "07/19 — C"]);
}
#[test]
fn upcoming_lines_empty_when_all_past() {
let list = vec![h("2026-01-01", "x", "x")];
assert!(upcoming_lines(&list, ymd(2026, 12, 31), Language::Local, 5).is_empty());
}
#[test]
fn upcoming_lines_drops_entries_with_unparseable_dates() {
let list = vec![
h("not-a-date", "Broken", "Broken"),
h("2026-05-03", "Constitution", "Constitution Day"),
];
let lines = upcoming_lines(&list, ymd(2026, 5, 1), Language::English, 5);
assert_eq!(lines, vec!["05/03 — Constitution Day"]);
}
#[test]
fn month_calendar_drops_entries_with_unparseable_dates() {
let list = vec![
h("not-a-date", "Broken", "Broken"),
h("2026-04-29", "Showa", "Showa Day"),
];
let cal = month_calendar(&list, ymd(2026, 4, 15));
assert_eq!(cal.events, vec![29]);
}
#[test]
fn month_calendar_filters_events_to_current_month() {
let list = vec![
h("2026-04-29", "x", "x"),
h("2026-05-03", "y", "y"),
h("2026-04-01", "z", "z"),
];
let cal = month_calendar(&list, ymd(2026, 4, 15));
assert_eq!(cal.year, 2026);
assert_eq!(cal.month, 4);
assert_eq!(cal.day, Some(15));
assert_eq!(cal.events, vec![29, 1]);
}
#[test]
fn render_body_text_emits_today_match_or_empty_string() {
let list = vec![h("2026-04-29", "Showa", "Showa Day")];
let matched = render_body(&list, Shape::Text, ymd(2026, 4, 29), Language::English, 5);
assert!(matches!(matched, Body::Text(t) if t.value == "🎌 Showa Day"));
let no_match = render_body(&list, Shape::Text, ymd(2026, 5, 1), Language::English, 5);
assert!(matches!(no_match, Body::Text(t) if t.value.is_empty()));
}
#[test]
fn render_body_textblock_emits_upcoming() {
let list = vec![h("2026-05-03", "x", "x")];
let body = render_body(
&list,
Shape::TextBlock,
ymd(2026, 5, 1),
Language::English,
5,
);
assert!(matches!(body, Body::TextBlock(t) if t.lines == ["05/03 — x"]));
}
#[test]
fn render_body_calendar_default_branch_emits_calendar() {
let body = render_body(&[], Shape::Calendar, ymd(2026, 4, 15), Language::Local, 5);
assert!(matches!(
body,
Body::Calendar(c) if (c.year, c.month, c.day) == (2026, 4, Some(15))
));
}
#[test]
fn sample_body_provides_a_value_for_each_supported_shape() {
let f = CalendarHolidaysFetcher;
for s in [Shape::Calendar, Shape::Text, Shape::TextBlock] {
assert!(f.sample_body(s).is_some(), "missing sample for {s:?}");
}
assert!(f.sample_body(Shape::Heatmap).is_none());
}
#[test]
fn fetcher_contract_and_cache_key_cover_catalog_surface() {
let fetcher = CalendarHolidaysFetcher;
assert_eq!(fetcher.name(), "calendar_holidays");
assert_eq!(fetcher.safety(), Safety::Safe);
assert_eq!(fetcher.default_shape(), Shape::Calendar);
assert_eq!(fetcher.shapes(), SHAPES);
assert_eq!(fetcher.option_schemas()[0].name, "country");
assert_eq!(fetcher.option_schemas()[1].name, "language");
assert_eq!(fetcher.option_schemas()[2].name, "limit");
assert!(fetcher.description().contains("date.nager.at"));
let default = fetcher.cache_key(&ctx(Some("country = \"JP\""), Some(Shape::Calendar)));
let other_country =
fetcher.cache_key(&ctx(Some("country = \"US\""), Some(Shape::Calendar)));
let other_shape = fetcher.cache_key(&ctx(Some("country = \"JP\""), Some(Shape::Text)));
assert_ne!(default, other_country);
assert_ne!(default, other_shape);
}
#[test]
fn http_reuses_the_same_client() {
assert!(std::ptr::eq(http(), http()));
}
#[test]
fn fetch_rejects_invalid_options_before_request() {
let fetcher = CalendarHolidaysFetcher;
let err = run_async(fetcher.fetch(&ctx(
Some("country = \"JP\"\nextra = true"),
Some(Shape::Calendar),
)))
.unwrap_err();
assert!(matches!(err, FetchError::Failed(msg) if msg.contains("invalid options")));
}
#[test]
fn fetch_requires_country_before_request() {
let fetcher = CalendarHolidaysFetcher;
let err = run_async(fetcher.fetch(&ctx(None, Some(Shape::Text)))).unwrap_err();
assert!(matches!(err, FetchError::Failed(msg) if msg.contains("requires `country`")));
}
#[test]
fn fetch_rejects_invalid_country_before_request() {
let fetcher = CalendarHolidaysFetcher;
let err = run_async(fetcher.fetch(&ctx(
Some("country = \"Japan\"\nlimit = 0"),
Some(Shape::TextBlock),
)))
.unwrap_err();
assert!(matches!(err, FetchError::Failed(msg) if msg.contains("invalid country code")));
}
#[test]
fn holiday_deserializes_real_nager_payload() {
let raw = r#"[
{ "date": "2026-04-29", "localName": "昭和の日", "name": "Showa Day", "countryCode": "JP" },
{ "date": "2026-05-03", "localName": "憲法記念日", "name": "Constitution Memorial Day", "countryCode": "JP" }
]"#;
let holidays: Vec<Holiday> = serde_json::from_str(raw).unwrap();
assert_eq!(holidays.len(), 2);
assert_eq!(holidays[0].label(Language::Local), "昭和の日");
assert_eq!(
holidays[1].label(Language::English),
"Constitution Memorial Day"
);
}
#[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);
}
#[tokio::test]
#[ignore]
async fn live_jp_returns_holidays_for_current_year() {
let today = Utc::now().date_naive();
let holidays = fetch_year(today.year(), "JP").await.unwrap();
assert!(!holidays.is_empty());
for h in &holidays {
eprintln!("{} — {} / {}", h.date, h.local_name, h.name);
}
}
}