splashboard 1.0.0

A customizable terminal splash screen with plugin-based data sources
Documentation
//! Shared HTTP client + URL helpers for the `wikipedia_*` family. Targets the public
//! Wikipedia REST API at `https://{lang}.wikipedia.org/api/rest_v1/...` — host stays inside
//! `*.wikipedia.org` regardless of which language code the config supplies, so every fetcher
//! in the family is Safety::Safe.

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}")))
}

/// Page summary returned by `/page/random/summary`, embedded inside `feed/featured`'s `tfa`
/// block, and inside each on-this-day event's `pages[]`. Featured / random share the rendering
/// helpers below; on-this-day uses only `url()` to wire up event links.
#[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())
    }

    /// First sentence of the extract — split at the first `". "` so the Text shape can carry a
    /// preview without the whole multi-paragraph body. Returns `None` when the extract is
    /// missing or empty.
    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())
    }
}

/// Shape-aware rendering for a single page summary — shared by `wikipedia_featured` and
/// `wikipedia_random` since both fetchers expose the same `{title, extract, url}` triplet.
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());
    }
}