use std::sync::OnceLock;
use std::time::Duration;
use reqwest::Client;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use crate::fetcher::FetchError;
use crate::payload::{Body, LinkedLine, LinkedTextBlockData, TextBlockData, TextData};
use crate::render::Shape;
pub const REST_API_PATH: &str = "/api/rest_v1";
pub const DEFAULT_LANG: &str = "en";
const USER_AGENT: &str = concat!(
"splashboard/",
env!("CARGO_PKG_VERSION"),
" (https://github.com/unhappychoice/splashboard-2)"
);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
pub 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")
})
}
pub fn rest_api_base(lang: &str) -> String {
format!("https://{lang}.wikipedia.org{REST_API_PATH}")
}
pub async fn get<T: DeserializeOwned>(url: &str) -> Result<T, FetchError> {
let res = http()
.get(url)
.send()
.await
.map_err(|e| FetchError::Failed(format!("wikipedia request failed: {e}")))?;
let status = res.status();
if !status.is_success() {
return Err(FetchError::Failed(format!("wikipedia {status}")));
}
res.json()
.await
.map_err(|e| FetchError::Failed(format!("wikipedia json parse: {e}")))
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct PageSummary {
#[serde(default)]
pub title: String,
#[serde(default)]
pub extract: Option<String>,
#[serde(default)]
pub content_urls: Option<ContentUrls>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ContentUrls {
#[serde(default)]
pub desktop: Option<UrlsByPlatform>,
#[serde(default)]
pub mobile: Option<UrlsByPlatform>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UrlsByPlatform {
pub page: String,
}
impl PageSummary {
pub fn url(&self) -> Option<String> {
self.content_urls
.as_ref()
.and_then(|c| c.desktop.as_ref().or(c.mobile.as_ref()))
.map(|p| p.page.clone())
}
pub fn first_sentence(&self) -> Option<String> {
let extract = self.extract.as_deref()?.trim();
if extract.is_empty() {
return None;
}
let cut = extract.find(". ").map(|i| i + 1).unwrap_or(extract.len());
Some(extract[..cut].trim().to_string())
}
}
pub fn render_page_summary(summary: &PageSummary, shape: Shape) -> Body {
match shape {
Shape::Text => Body::Text(TextData {
value: text_line(summary),
}),
Shape::TextBlock => Body::TextBlock(TextBlockData {
lines: text_block_lines(summary),
}),
_ => Body::LinkedTextBlock(LinkedTextBlockData {
items: vec![LinkedLine {
text: summary.title.clone(),
url: summary.url(),
}],
}),
}
}
fn text_line(summary: &PageSummary) -> String {
match summary.first_sentence() {
Some(sentence) => format!("{}: {sentence}", summary.title),
None => summary.title.clone(),
}
}
fn text_block_lines(summary: &PageSummary) -> Vec<String> {
let mut lines = vec![summary.title.clone()];
if let Some(extract) = summary.extract.as_deref().filter(|s| !s.is_empty()) {
for line in extract.lines().filter(|l| !l.trim().is_empty()) {
lines.push(line.to_string());
}
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
fn summary_with(extract: Option<&str>, page_url: Option<&str>) -> PageSummary {
PageSummary {
title: "Quokka".into(),
extract: extract.map(String::from),
content_urls: page_url.map(|p| ContentUrls {
desktop: Some(UrlsByPlatform { page: p.into() }),
mobile: None,
}),
}
}
#[test]
fn rest_api_base_uses_lang_subdomain() {
assert_eq!(rest_api_base("ja"), "https://ja.wikipedia.org/api/rest_v1");
}
#[test]
fn url_prefers_desktop_over_mobile() {
let s = summary_with(None, Some("https://en.wikipedia.org/wiki/Quokka"));
assert_eq!(
s.url().as_deref(),
Some("https://en.wikipedia.org/wiki/Quokka")
);
}
#[test]
fn url_falls_back_to_mobile_when_desktop_absent() {
let s = PageSummary {
title: "x".into(),
extract: None,
content_urls: Some(ContentUrls {
desktop: None,
mobile: Some(UrlsByPlatform {
page: "https://en.m.wikipedia.org/wiki/x".into(),
}),
}),
};
assert_eq!(
s.url().as_deref(),
Some("https://en.m.wikipedia.org/wiki/x")
);
}
#[test]
fn url_is_none_when_content_urls_absent() {
assert!(summary_with(None, None).url().is_none());
}
#[test]
fn first_sentence_splits_on_period_space() {
let s = summary_with(Some("Apollo 11 was a mission. It landed in 1969."), None);
assert_eq!(
s.first_sentence().as_deref(),
Some("Apollo 11 was a mission.")
);
}
#[test]
fn first_sentence_returns_full_extract_when_no_period_space() {
let s = summary_with(Some("Single fragment"), None);
assert_eq!(s.first_sentence().as_deref(), Some("Single fragment"));
}
#[test]
fn first_sentence_returns_none_when_extract_missing_or_blank() {
assert!(summary_with(None, None).first_sentence().is_none());
assert!(summary_with(Some(" "), None).first_sentence().is_none());
}
#[test]
fn render_text_combines_title_and_first_sentence() {
let s = summary_with(Some("It hops. It is fluffy."), None);
let body = render_page_summary(&s, Shape::Text);
let Body::Text(t) = body else {
panic!("expected text");
};
assert_eq!(t.value, "Quokka: It hops.");
}
#[test]
fn render_text_falls_back_to_title_when_extract_missing() {
let body = render_page_summary(&summary_with(None, None), Shape::Text);
let Body::Text(t) = body else {
panic!("expected text");
};
assert_eq!(t.value, "Quokka");
}
#[test]
fn render_text_block_emits_title_then_extract_lines() {
let s = summary_with(Some("Para one.\n\nPara two."), None);
let body = render_page_summary(&s, Shape::TextBlock);
let Body::TextBlock(t) = body else {
panic!("expected text block");
};
assert_eq!(t.lines, vec!["Quokka", "Para one.", "Para two."]);
}
#[test]
fn render_text_block_emits_title_only_when_extract_empty() {
let s = summary_with(Some(""), None);
let body = render_page_summary(&s, Shape::TextBlock);
let Body::TextBlock(t) = body else {
panic!("expected text block");
};
assert_eq!(t.lines, vec!["Quokka"]);
}
#[test]
fn render_linked_text_block_uses_summary_url() {
let s = summary_with(None, Some("https://en.wikipedia.org/wiki/Quokka"));
let body = render_page_summary(&s, Shape::LinkedTextBlock);
let Body::LinkedTextBlock(b) = body else {
panic!("expected linked text block");
};
assert_eq!(b.items.len(), 1);
assert_eq!(b.items[0].text, "Quokka");
assert_eq!(
b.items[0].url.as_deref(),
Some("https://en.wikipedia.org/wiki/Quokka")
);
}
#[test]
fn render_linked_text_block_drops_url_when_absent() {
let body = render_page_summary(&summary_with(None, None), Shape::LinkedTextBlock);
let Body::LinkedTextBlock(b) = body else {
panic!("expected linked text block");
};
assert!(b.items[0].url.is_none());
}
#[test]
fn page_summary_deserializes_real_api_payload() {
let raw = r#"{
"title": "Quokka",
"extract": "The quokka is a small macropod.",
"content_urls": {
"desktop": { "page": "https://en.wikipedia.org/wiki/Quokka" }
}
}"#;
let s: PageSummary = serde_json::from_str(raw).unwrap();
assert_eq!(s.title, "Quokka");
assert_eq!(
s.extract.as_deref(),
Some("The quokka is a small macropod.")
);
assert_eq!(
s.url().as_deref(),
Some("https://en.wikipedia.org/wiki/Quokka")
);
}
#[test]
fn page_summary_deserializes_with_only_title() {
let raw = r#"{"title":"x"}"#;
let s: PageSummary = serde_json::from_str(raw).unwrap();
assert_eq!(s.title, "x");
assert!(s.extract.is_none());
assert!(s.url().is_none());
}
}