use std::sync::LazyLock;
use chrono::{DateTime, Datelike, FixedOffset, Local};
use crate::fetcher::{FetchContext, RealtimeFetcher, Safety};
use crate::payload::{Body, Payload, TextBlockData, TextData};
use crate::render::Shape;
use crate::samples;
const SHAPES: &[Shape] = &[Shape::TextBlock, Shape::Text];
static QUOTES: LazyLock<Vec<(String, String)>> = LazyLock::new(|| parse(include_str!("data.txt")));
pub struct RandomQuoteFetcher;
impl RealtimeFetcher for RandomQuoteFetcher {
fn name(&self) -> &str {
"random_quote"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Picks one quote-and-author pair from a bundled collection, advancing once per calendar day. Distinct from `random_fortune`, which serves anonymous Unix-style fortune cookies; this one always carries an attribution line."
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
let (q, a) = first_quote()?;
Some(match shape {
Shape::Text => samples::text(&single_line(q, a)),
Shape::TextBlock => samples::text_block(&[q, &format!("— {a}")]),
_ => return None,
})
}
fn compute(&self, ctx: &FetchContext) -> Payload {
let now = now();
let (quote, author) = pick(&now);
let body = match ctx.shape.unwrap_or(Shape::TextBlock) {
Shape::Text => Body::Text(TextData {
value: single_line(quote, author),
}),
_ => Body::TextBlock(TextBlockData {
lines: vec![quote.to_string(), format!("— {author}")],
}),
};
Payload {
icon: None,
status: None,
format: None,
body,
}
}
}
fn now() -> DateTime<FixedOffset> {
Local::now().fixed_offset()
}
fn pick(now: &DateTime<FixedOffset>) -> (&'static str, &'static str) {
let entries: &'static [(String, String)] = "ES;
let idx = (now.num_days_from_ce() as usize) % entries.len();
let (q, a) = &entries[idx];
(q.as_str(), a.as_str())
}
fn first_quote() -> Option<(&'static str, &'static str)> {
let entries: &'static [(String, String)] = "ES;
entries.first().map(|(q, a)| (q.as_str(), a.as_str()))
}
fn single_line(quote: &str, author: &str) -> String {
format!("{quote} — {author}")
}
fn parse(raw: &str) -> Vec<(String, String)> {
raw.replace("\r\n", "\n")
.split("\n%\n")
.filter_map(parse_entry)
.collect()
}
fn parse_entry(chunk: &str) -> Option<(String, String)> {
let trimmed = chunk.trim();
if trimmed.is_empty() {
return None;
}
let lines: Vec<&str> = trimmed.lines().collect();
let (author, quote_lines) = match lines.split_last() {
Some((last, rest)) if last.trim_start().starts_with("— ") => (
last.trim_start()
.trim_start_matches("— ")
.trim()
.to_string(),
rest,
),
_ => ("Anonymous".to_string(), lines.as_slice()),
};
let quote = quote_lines
.iter()
.map(|s| s.trim())
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_string();
if quote.is_empty() {
None
} else {
Some((quote, author))
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use chrono::{TimeZone, Utc};
use super::*;
fn at(y: i32, m: u32, d: u32) -> DateTime<FixedOffset> {
Utc.with_ymd_and_hms(y, m, d, 12, 0, 0)
.unwrap()
.fixed_offset()
}
fn ctx(shape: Option<Shape>) -> FetchContext {
FetchContext {
widget_id: "q".into(),
timeout: Duration::from_secs(1),
shape,
..Default::default()
}
}
#[test]
fn default_shape_is_text_block() {
let p = RandomQuoteFetcher.compute(&ctx(None));
assert!(matches!(
p.body,
Body::TextBlock(TextBlockData { ref lines })
if lines.len() == 2 && lines[1].starts_with("— ")
));
}
#[test]
fn text_shape_joins_with_dash() {
let p = RandomQuoteFetcher.compute(&ctx(Some(Shape::Text)));
assert!(matches!(
p.body,
Body::Text(TextData { ref value }) if value.contains(" — ")
));
}
#[test]
fn sample_body_supports_declared_shapes_and_rejects_unsupported() {
let fetcher = RandomQuoteFetcher;
assert!(matches!(
fetcher.sample_body(Shape::Text),
Some(Body::Text(TextData { ref value })) if value.contains(" — ")
));
assert!(matches!(
fetcher.sample_body(Shape::TextBlock),
Some(Body::TextBlock(TextBlockData { ref lines }))
if lines.len() == 2 && lines[1].starts_with("— ")
));
assert!(fetcher.sample_body(Shape::Image).is_none());
}
#[test]
fn pick_is_stable_within_a_day() {
let a = pick(&at(2026, 4, 24));
let b = pick(&at(2026, 4, 24));
assert_eq!(a, b);
}
#[test]
fn pick_advances_with_epoch_day() {
let a = pick(&at(2026, 4, 24));
let b = pick(&at(2026, 4, 25));
assert_ne!(a, b);
}
#[test]
fn pick_drifts_year_over_year() {
let a = pick(&at(2026, 4, 24));
let b = pick(&at(2027, 4, 24));
assert_ne!(
a, b,
"year-over-year drift broken — quote count may divide 365"
);
}
#[test]
fn quote_list_meets_size_floor() {
assert!(QUOTES.len() >= 365);
}
#[test]
fn parse_entry_extracts_author() {
let (q, a) = parse_entry("Hello world.\n— Someone").unwrap();
assert_eq!(q, "Hello world.");
assert_eq!(a, "Someone");
}
#[test]
fn parse_entry_falls_back_to_anonymous() {
let (q, a) = parse_entry("Just a line.").unwrap();
assert_eq!(q, "Just a line.");
assert_eq!(a, "Anonymous");
}
#[test]
fn parse_entry_joins_multiline_quote() {
let (q, a) = parse_entry("Line one\nLine two\n— Poet").unwrap();
assert_eq!(q, "Line one Line two");
assert_eq!(a, "Poet");
}
#[test]
fn parse_entry_drops_author_only_chunks() {
assert_eq!(parse_entry("— Someone"), None);
}
#[test]
fn parse_drops_empty_chunks() {
assert!(parse("\n%\n\n%\n").is_empty());
}
#[test]
fn quote_list_has_no_duplicates() {
use std::collections::HashSet;
let unique: HashSet<&str> = QUOTES.iter().map(|(q, _)| q.as_str()).collect();
assert_eq!(unique.len(), QUOTES.len());
}
#[test]
fn parse_handles_crlf_line_endings() {
let lf = "First.\n— A\n%\nSecond.\n— B\n";
let crlf = lf.replace('\n', "\r\n");
assert_eq!(parse(lf).len(), 2);
assert_eq!(parse(&crlf).len(), 2);
assert_eq!(parse(lf), parse(&crlf));
}
}