use async_trait::async_trait;
use chrono::{Datelike, Local, NaiveDate};
use serde::Deserialize;
use super::client::{
ApiEntry, MAX_LIST_LIMIT, cache_extra, cached_get_entries, entry_url, resolve_token,
};
use crate::fetcher::github::common::{cache_key, parse_options, parse_timestamp, payload};
use crate::fetcher::{FetchContext, FetchError, Fetcher, Safety};
use crate::options::OptionSchema;
use crate::payload::{
BadgeData, Body, CalendarData, EntriesData, Entry, LinkedLine, LinkedTextBlockData,
MarkdownTextBlockData, Payload, Status, TextBlockData, TextData, TimelineData, TimelineEvent,
};
use crate::render::Shape;
use crate::samples;
const DEFAULT_LIMIT: u32 = 10;
const MIN_LIMIT: u32 = 1;
const SHAPES: &[Shape] = &[
Shape::Timeline,
Shape::Text,
Shape::TextBlock,
Shape::MarkdownTextBlock,
Shape::LinkedTextBlock,
Shape::Entries,
Shape::Badge,
Shape::Calendar,
];
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "token",
type_hint: "string",
required: false,
default: None,
description: "Deariary API token. Falls back to the `DEARIARY_TOKEN` env var.",
},
OptionSchema {
name: "limit",
type_hint: "integer (1..=100)",
required: false,
default: Some("10"),
description: "Maximum number of entries to render in row-shaped views.",
},
OptionSchema {
name: "tag",
type_hint: "string",
required: false,
default: None,
description: "Restrict results to a single tag slug.",
},
];
pub struct DeariaryRecent;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
#[serde(default)]
token: Option<String>,
#[serde(default)]
limit: Option<u32>,
#[serde(default)]
tag: Option<String>,
}
#[async_trait]
impl Fetcher for DeariaryRecent {
fn name(&self) -> &str {
"deariary_recent"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Recent auto-generated deariary.com entries, newest first. `Timeline` (default) carries the chronological ranking; `TextBlock` / `Entries` / `MarkdownTextBlock` / `LinkedTextBlock` carry the same ranking in their respective row formats; `Calendar` highlights this month's entry days; `Text` and `Badge` summarise the headline."
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn cache_key(&self, ctx: &FetchContext) -> String {
let opts: Options = parse_options(ctx.options.as_ref()).unwrap_or_default();
let extra = cache_extra(opts.token.as_deref(), ctx.options.as_ref());
cache_key(self.name(), ctx, &extra)
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
Some(match shape {
Shape::Timeline => samples::timeline(&[
(1_777_478_400, "My adventurous day", Some("482w")),
(1_777_392_000, "A quiet Sunday", Some("310w")),
(1_777_305_600, "Shipped the heatmap renderer", Some("612w")),
]),
Shape::Text => samples::text("📔 3 entries · latest: My adventurous day"),
Shape::TextBlock => samples::text_block(&[
"04/27 · My adventurous day",
"04/26 · A quiet Sunday",
"04/25 · Shipped the heatmap renderer",
]),
Shape::MarkdownTextBlock => samples::markdown(
"- **04/27** — My adventurous day\n- **04/26** — A quiet Sunday\n- **04/25** — Shipped the heatmap renderer",
),
Shape::LinkedTextBlock => samples::linked_text_block(&[
(
"04/27 · My adventurous day",
Some("https://app.deariary.com/entries/2026/04/27"),
),
(
"04/26 · A quiet Sunday",
Some("https://app.deariary.com/entries/2026/04/26"),
),
(
"04/25 · Shipped the heatmap renderer",
Some("https://app.deariary.com/entries/2026/04/25"),
),
]),
Shape::Entries => samples::entries(&[
("2026-04-27", "My adventurous day"),
("2026-04-26", "A quiet Sunday"),
("2026-04-25", "Shipped the heatmap renderer"),
]),
Shape::Badge => Body::Badge(BadgeData {
status: Status::Ok,
label: "📔 3 recent".into(),
}),
Shape::Calendar => samples::calendar(2026, 4, Some(27), &[25, 26, 27]),
_ => 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 token = resolve_token(opts.token.as_deref())?;
let limit = opts
.limit
.unwrap_or(DEFAULT_LIMIT)
.clamp(MIN_LIMIT, MAX_LIST_LIMIT);
let entries = cached_get_entries(&token, opts.tag.as_deref()).await?;
let shape = ctx.shape.unwrap_or(Shape::Timeline);
let today = Local::now().date_naive();
Ok(payload(render_body(&entries, shape, limit as usize, today)))
}
}
fn render_body(entries: &[ApiEntry], shape: Shape, limit: usize, today: NaiveDate) -> Body {
let limited: Vec<&ApiEntry> = entries.iter().take(limit).collect();
match shape {
Shape::Text => render_text(entries),
Shape::Badge => render_badge(entries),
Shape::TextBlock => render_text_block(&limited),
Shape::MarkdownTextBlock => render_markdown(&limited),
Shape::LinkedTextBlock => render_linked(&limited),
Shape::Entries => render_entries(&limited),
Shape::Calendar => render_calendar(entries, today),
_ => render_timeline(&limited),
}
}
fn render_text(entries: &[ApiEntry]) -> Body {
let value = entries.first().map_or(String::new(), |e| {
format!("📔 {} entries · latest: {}", entries.len(), e.title)
});
Body::Text(TextData { value })
}
fn render_text_block(entries: &[&ApiEntry]) -> Body {
Body::TextBlock(TextBlockData {
lines: entries.iter().map(|e| line_for(e)).collect(),
})
}
fn render_markdown(entries: &[&ApiEntry]) -> Body {
let value = entries
.iter()
.map(|e| format!("- **{}** — {}", short_date(&e.date), e.title))
.collect::<Vec<_>>()
.join("\n");
Body::MarkdownTextBlock(MarkdownTextBlockData { value })
}
fn render_linked(entries: &[&ApiEntry]) -> Body {
Body::LinkedTextBlock(LinkedTextBlockData {
items: entries
.iter()
.map(|e| LinkedLine {
text: line_for(e),
url: Some(entry_url(&e.date)),
})
.collect(),
})
}
fn render_entries(entries: &[&ApiEntry]) -> Body {
Body::Entries(EntriesData {
items: entries
.iter()
.map(|e| Entry {
key: e.date.clone(),
value: Some(e.title.clone()),
status: None,
})
.collect(),
})
}
fn render_badge(entries: &[ApiEntry]) -> Body {
Body::Badge(BadgeData {
status: if entries.is_empty() {
Status::Warn
} else {
Status::Ok
},
label: format!("📔 {} recent", entries.len()),
})
}
fn render_timeline(entries: &[&ApiEntry]) -> Body {
Body::Timeline(TimelineData {
events: entries
.iter()
.map(|e| TimelineEvent {
timestamp: timestamp_for(e),
title: e.title.clone(),
detail: None,
status: None,
})
.collect(),
})
}
fn render_calendar(entries: &[ApiEntry], today: NaiveDate) -> Body {
let events = entries
.iter()
.filter_map(|e| NaiveDate::parse_from_str(&e.date, "%Y-%m-%d").ok())
.filter(|d| d.year() == today.year() && d.month() == today.month())
.map(|d| d.day() as u8)
.collect();
Body::Calendar(CalendarData {
year: today.year(),
month: today.month() as u8,
day: Some(today.day() as u8),
events,
})
}
fn line_for(e: &ApiEntry) -> String {
format!("{} · {}", short_date(&e.date), e.title)
}
fn short_date(raw: &str) -> String {
NaiveDate::parse_from_str(raw, "%Y-%m-%d")
.map(|d| d.format("%m/%d").to_string())
.unwrap_or_else(|_| raw.to_string())
}
fn timestamp_for(e: &ApiEntry) -> i64 {
e.generated_at
.as_deref()
.map(parse_timestamp)
.filter(|ts| *ts > 0)
.or_else(|| {
NaiveDate::parse_from_str(&e.date, "%Y-%m-%d")
.ok()
.and_then(|d| d.and_hms_opt(12, 0, 0))
.map(|dt| dt.and_utc().timestamp())
})
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use std::{future::Future, time::Duration};
use super::*;
fn entry(date: &str, title: &str) -> ApiEntry {
ApiEntry {
date: date.into(),
title: title.into(),
content: None,
tags: vec![],
sources: vec![],
generated_at: None,
word_count: None,
}
}
fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn fixture() -> Vec<ApiEntry> {
vec![
entry("2026-04-27", "My Day"),
entry("2026-04-26", "Sunday"),
entry("2026-04-25", "Heatmap"),
]
}
fn ctx(options: Option<&str>, shape: Option<Shape>, format: Option<&str>) -> FetchContext {
FetchContext {
widget_id: "deariary_recent".into(),
format: format.map(str::to_string),
timeout: Duration::from_secs(1),
shape,
options: options.map(|raw| toml::from_str(raw).unwrap()),
..Default::default()
}
}
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 fetcher_catalog_surface_matches_contract() {
let fetcher = DeariaryRecent;
assert_eq!(fetcher.name(), "deariary_recent");
assert_eq!(fetcher.safety(), Safety::Safe);
assert_eq!(fetcher.default_shape(), Shape::Timeline);
assert_eq!(fetcher.shapes(), SHAPES);
assert_eq!(
fetcher
.option_schemas()
.iter()
.map(|schema| schema.name)
.collect::<Vec<_>>(),
vec!["token", "limit", "tag"]
);
assert!(fetcher.description().contains("Recent auto-generated"));
}
#[test]
fn cache_key_changes_with_shape_format_and_options() {
let fetcher = DeariaryRecent;
let base = fetcher.cache_key(&ctx(
Some("token = \"alpha\"\ntag = \"ops\""),
Some(Shape::Timeline),
Some("plain"),
));
assert_eq!(
base,
fetcher.cache_key(&ctx(
Some("token = \"alpha\"\ntag = \"ops\""),
Some(Shape::Timeline),
Some("plain"),
))
);
assert_ne!(
base,
fetcher.cache_key(&ctx(
Some("token = \"beta\"\ntag = \"ops\""),
Some(Shape::Timeline),
Some("plain"),
))
);
assert_ne!(
base,
fetcher.cache_key(&ctx(
Some("token = \"alpha\"\ntag = \"personal\""),
Some(Shape::Timeline),
Some("plain"),
))
);
assert_ne!(
base,
fetcher.cache_key(&ctx(
Some("token = \"alpha\"\ntag = \"ops\""),
Some(Shape::TextBlock),
Some("plain"),
))
);
assert_ne!(
base,
fetcher.cache_key(&ctx(
Some("token = \"alpha\"\ntag = \"ops\""),
Some(Shape::Timeline),
Some("json"),
))
);
assert!(
!fetcher
.cache_key(&ctx(Some("bogus = true"), None, None))
.is_empty()
);
}
#[test]
fn timeline_uses_date_when_generated_at_missing() {
let entries = fixture();
assert!(matches!(
render_body(&entries, Shape::Timeline, 10, ymd(2026, 4, 27)),
Body::Timeline(TimelineData { events })
if events.len() == 3
&& events.iter().all(|event| event.timestamp > 0)
&& events[0].title == "My Day"
));
}
#[test]
fn timeline_prefers_generated_at_when_present() {
let mut entries = fixture();
entries[0].generated_at = Some("2026-04-27T08:00:00Z".into());
assert!(matches!(
render_body(&entries, Shape::Timeline, 10, ymd(2026, 4, 27)),
Body::Timeline(TimelineData { events })
if events[0].timestamp == parse_timestamp("2026-04-27T08:00:00Z")
));
}
#[test]
fn text_includes_count_and_latest_title() {
let entries = fixture();
assert_eq!(
render_body(&entries, Shape::Text, 10, ymd(2026, 4, 27)),
Body::Text(TextData {
value: "📔 3 entries · latest: My Day".into()
})
);
}
#[test]
fn text_block_formats_short_date_and_title() {
let entries = fixture();
assert!(matches!(
render_body(&entries, Shape::TextBlock, 10, ymd(2026, 4, 27)),
Body::TextBlock(TextBlockData { lines }) if lines[0] == "04/27 · My Day"
));
}
#[test]
fn linked_text_block_links_each_row_to_its_entry_page() {
let entries = fixture();
assert!(matches!(
render_body(&entries, Shape::LinkedTextBlock, 10, ymd(2026, 4, 27)),
Body::LinkedTextBlock(LinkedTextBlockData { items })
if items.len() == 3
&& items[0].url.as_deref() == Some("https://app.deariary.com/entries/2026/04/27")
));
}
#[test]
fn markdown_uses_bold_dates() {
let entries = fixture();
assert!(matches!(
render_body(&entries, Shape::MarkdownTextBlock, 10, ymd(2026, 4, 27)),
Body::MarkdownTextBlock(MarkdownTextBlockData { value }) if value.contains("**04/27**")
));
}
#[test]
fn entries_keys_with_iso_date() {
let entries = fixture();
assert!(matches!(
render_body(&entries, Shape::Entries, 10, ymd(2026, 4, 27)),
Body::Entries(EntriesData { items })
if items[0].key == "2026-04-27" && items[0].value.as_deref() == Some("My Day")
));
}
#[test]
fn calendar_marks_entries_for_current_month_only() {
let mut entries = fixture();
entries.push(entry("2026-03-15", "Old"));
entries.push(entry("not-a-date", "Broken"));
assert_eq!(
render_body(&entries, Shape::Calendar, 10, ymd(2026, 4, 27)),
Body::Calendar(CalendarData {
year: 2026,
month: 4,
day: Some(27),
events: vec![27, 26, 25],
})
);
}
#[test]
fn limit_caps_returned_rows() {
let entries = fixture();
assert_eq!(
render_body(&entries, Shape::TextBlock, 2, ymd(2026, 4, 27)),
Body::TextBlock(TextBlockData {
lines: vec!["04/27 · My Day".into(), "04/26 · Sunday".into()],
})
);
}
#[test]
fn text_headline_reports_total_count_not_limit() {
let entries = vec![
entry("2026-04-27", "A"),
entry("2026-04-26", "B"),
entry("2026-04-25", "C"),
entry("2026-04-24", "D"),
entry("2026-04-23", "E"),
];
assert_eq!(
render_body(&entries, Shape::Text, 2, ymd(2026, 4, 27)),
Body::Text(TextData {
value: "📔 5 entries · latest: A".into()
})
);
}
#[test]
fn badge_headline_reports_total_count_not_limit() {
let entries = vec![
entry("2026-04-27", "A"),
entry("2026-04-26", "B"),
entry("2026-04-25", "C"),
];
assert_eq!(
render_body(&entries, Shape::Badge, 1, ymd(2026, 4, 27)),
Body::Badge(BadgeData {
status: Status::Ok,
label: "📔 3 recent".into(),
})
);
}
#[test]
fn empty_input_yields_empty_body_and_warn_badge() {
assert_eq!(
render_body(&[], Shape::Text, 10, ymd(2026, 4, 27)),
Body::Text(TextData {
value: String::new()
})
);
assert_eq!(
render_body(&[], Shape::Timeline, 10, ymd(2026, 4, 27)),
Body::Timeline(TimelineData { events: vec![] })
);
assert_eq!(
render_body(&[], Shape::Badge, 10, ymd(2026, 4, 27)),
Body::Badge(BadgeData {
status: Status::Warn,
label: "📔 0 recent".into(),
})
);
}
#[test]
fn sample_body_provides_value_for_each_supported_shape() {
let f = DeariaryRecent;
for s in SHAPES {
assert!(f.sample_body(*s).is_some(), "missing sample for {s:?}");
}
assert!(f.sample_body(Shape::Image).is_none());
}
#[test]
fn options_reject_unknown_keys() {
let raw: toml::Value = toml::from_str("limit = 5\nbogus = 1").unwrap();
assert!(raw.try_into::<Options>().is_err());
}
#[test]
fn short_date_falls_back_to_raw_when_unparseable() {
assert_eq!(short_date("not-a-date"), "not-a-date");
}
#[test]
fn timestamp_for_filters_invalid_generated_at_and_bad_dates() {
let mut valid_date = entry("2026-04-27", "Fallback");
valid_date.generated_at = Some("0".into());
assert_eq!(
timestamp_for(&valid_date),
ymd(2026, 4, 27)
.and_hms_opt(12, 0, 0)
.unwrap()
.and_utc()
.timestamp()
);
let mut invalid = entry("not-a-date", "Broken");
invalid.generated_at = Some("nope".into());
assert_eq!(timestamp_for(&invalid), 0);
}
#[test]
fn unsupported_shape_falls_back_to_timeline() {
let entries = fixture();
assert!(matches!(
render_body(&entries, Shape::Image, 2, ymd(2026, 4, 27)),
Body::Timeline(TimelineData { events }) if events.len() == 2
));
}
#[test]
fn fetch_rejects_unknown_options() {
let fetcher = DeariaryRecent;
let err = run_async(fetcher.fetch(&ctx(
Some("token = \"abc\"\nbogus = true"),
Some(Shape::Timeline),
None,
)))
.unwrap_err();
assert!(matches!(
err,
FetchError::Failed(msg) if msg.contains("unknown field `bogus`")
));
}
#[test]
fn fetch_requires_token_before_network() {
struct RestoreEnv {
key: &'static str,
previous: Option<String>,
}
impl Drop for RestoreEnv {
fn drop(&mut self) {
unsafe {
match &self.previous {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
}
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _restore = RestoreEnv {
key: "DEARIARY_TOKEN",
previous: std::env::var("DEARIARY_TOKEN").ok(),
};
unsafe { std::env::remove_var("DEARIARY_TOKEN") };
let fetcher = DeariaryRecent;
let err = run_async(fetcher.fetch(&ctx(None, Some(Shape::Timeline), None))).unwrap_err();
assert!(matches!(
err,
FetchError::Failed(msg) if msg == "deariary token missing: set options.token or DEARIARY_TOKEN"
));
}
}