use async_trait::async_trait;
use serde::Deserialize;
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::{
Bar, BarsData, Body, EntriesData, Entry, LinkedLine, LinkedTextBlockData,
MarkdownTextBlockData, Payload, TextBlockData, TextData, TimelineData, TimelineEvent,
};
use crate::render::Shape;
use crate::samples;
use super::client::{SITE_BASE, get};
const DEFAULT_COUNT: u32 = 10;
const MIN_COUNT: u32 = 1;
const MAX_COUNT: u32 = 25;
const TAG_MAX_LEN: usize = 32;
const SHAPES: &[Shape] = &[
Shape::LinkedTextBlock,
Shape::TextBlock,
Shape::Text,
Shape::MarkdownTextBlock,
Shape::Entries,
Shape::Bars,
Shape::Timeline,
];
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "count",
type_hint: "integer (1..=25)",
required: false,
default: Some("10"),
description: "Number of stories to display.",
},
OptionSchema {
name: "kind",
type_hint: "\"hottest\" | \"newest\" | \"active\"",
required: false,
default: Some("\"hottest\""),
description: "Which Lobsters listing to query. Forced to `hottest` when `tag` is set (the tag listing only exposes the hottest variant).",
},
OptionSchema {
name: "tag",
type_hint: "string",
required: false,
default: None,
description: "Filter to a single Lobsters tag (e.g. `\"rust\"`, `\"c++\"`). Allowed chars: `[a-z0-9_+-]`.",
},
];
pub struct LobstersTopFetcher;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Options {
#[serde(default)]
pub count: Option<u32>,
#[serde(default)]
pub kind: Option<Kind>,
#[serde(default)]
pub tag: Option<String>,
}
#[derive(Debug, Default, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
#[default]
Hottest,
Newest,
Active,
}
impl Kind {
fn endpoint(self) -> &'static str {
match self {
Self::Hottest => "hottest",
Self::Newest => "newest",
Self::Active => "active",
}
}
}
#[async_trait]
impl Fetcher for LobstersTopFetcher {
fn name(&self) -> &str {
"lobsters_top"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Lobsters front-page listings — `hottest` / `newest` / `active`, optionally filtered by a single tag. Each row shows score, comment count, and title, linked to the story URL (or the Lobsters comment page when there isn't one)."
}
fn refresh_interval(&self) -> u64 {
60 * 10
}
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::Text => samples::text("75pt 17c Show: terminal dashboard with local trust"),
Shape::TextBlock => samples::text_block(&[
"75pt 17c Show: terminal dashboard with local trust",
"41pt 6c Fake Notepad++ for Mac",
"29pt 2c This Wasm interpreter fits in a QR code",
]),
Shape::LinkedTextBlock => samples::linked_text_block(&[
(
"75pt 17c Show: terminal dashboard with local trust",
Some("https://example.com/show"),
),
(
"41pt 6c Fake Notepad++ for Mac",
Some("https://example.com/np"),
),
(
"29pt 2c This Wasm interpreter fits in a QR code",
Some("https://lobste.rs/s/abc123"),
),
]),
Shape::MarkdownTextBlock => samples::markdown(
"- **75pt 17c** [Show: terminal dashboard with local trust](https://example.com/show)\n\
- **41pt 6c** [Fake Notepad++ for Mac](https://example.com/np)\n\
- **29pt 2c** [This Wasm interpreter fits in a QR code](https://lobste.rs/s/abc123)",
),
Shape::Entries => samples::entries(&[
("Show: terminal dashboard with local trust", "75pt 17c"),
("Fake Notepad++ for Mac", "41pt 6c"),
("This Wasm interpreter fits in a QR code", "29pt 2c"),
]),
Shape::Bars => Body::Bars(BarsData {
bars: vec![
Bar {
label: "Show: terminal dashboard …".into(),
value: 75,
value_label: None,
},
Bar {
label: "Fake Notepad++ for Mac".into(),
value: 41,
value_label: None,
},
Bar {
label: "This Wasm interpreter fits …".into(),
value: 29,
value_label: None,
},
],
}),
Shape::Timeline => samples::timeline(&[
(
1_777_708_434,
"Show: terminal dashboard with local trust",
Some("75pt 17c"),
),
(1_777_705_489, "Fake Notepad++ for Mac", Some("41pt 6c")),
(
1_777_705_205,
"This Wasm interpreter fits in a QR code",
Some("29pt 2c"),
),
]),
_ => 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 count = opts
.count
.unwrap_or(DEFAULT_COUNT)
.clamp(MIN_COUNT, MAX_COUNT) as usize;
let kind = opts.kind.unwrap_or_default();
let tag = opts.tag.as_deref().map(normalize_tag).transpose()?;
let path = listing_path(kind, tag.as_deref());
let items = fetch_stories(&path, count).await?;
Ok(payload(render_body(
&items,
ctx.shape.unwrap_or(Shape::LinkedTextBlock),
)))
}
}
fn listing_path(kind: Kind, tag: Option<&str>) -> String {
match tag {
Some(t) => format!("/t/{t}.json"),
None => format!("/{}.json", kind.endpoint()),
}
}
fn normalize_tag(raw: &str) -> Result<String, FetchError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(FetchError::Failed("tag must not be empty".into()));
}
if trimmed.len() > TAG_MAX_LEN {
return Err(FetchError::Failed("tag is too long".into()));
}
if !trimmed
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '+' | '-'))
{
return Err(FetchError::Failed(
"tag must contain only [a-z0-9_+-]".into(),
));
}
Ok(trimmed.to_ascii_lowercase())
}
async fn fetch_stories(path: &str, count: usize) -> Result<Vec<Item>, FetchError> {
let items: Vec<Item> = get(&format!("{SITE_BASE}{path}")).await?;
Ok(items.into_iter().take(count).collect())
}
#[derive(Debug, Deserialize)]
struct Item {
#[serde(default)]
short_id: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
score: Option<i64>,
#[serde(default)]
comment_count: Option<u64>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
comments_url: Option<String>,
#[serde(default)]
short_id_url: Option<String>,
#[serde(default)]
created_at: Option<String>,
}
fn link_for(it: &Item) -> Option<String> {
it.url
.clone()
.filter(|u| !u.is_empty())
.or_else(|| it.comments_url.clone())
.or_else(|| it.short_id_url.clone())
.or_else(|| {
it.short_id
.as_deref()
.map(|id| format!("{SITE_BASE}/s/{id}"))
})
}
fn render_body(items: &[Item], shape: Shape) -> Body {
match shape {
Shape::Text => Body::Text(TextData {
value: items
.first()
.map(|it| format!("{} {}", meta_label(it), title_or_placeholder(it)))
.unwrap_or_default(),
}),
Shape::Entries => Body::Entries(EntriesData {
items: items
.iter()
.map(|it| Entry {
key: title_or_placeholder(it),
value: Some(meta_label(it)),
status: None,
})
.collect(),
}),
Shape::Bars => Body::Bars(BarsData {
bars: items
.iter()
.map(|it| Bar {
label: title_or_placeholder(it),
value: it.score.unwrap_or(0).max(0) as u64,
value_label: None,
})
.collect(),
}),
Shape::LinkedTextBlock => Body::LinkedTextBlock(LinkedTextBlockData {
items: items
.iter()
.map(|it| LinkedLine {
text: format!("{} {}", meta_label(it), title_or_placeholder(it)),
url: link_for(it),
})
.collect(),
}),
Shape::MarkdownTextBlock => Body::MarkdownTextBlock(MarkdownTextBlockData {
value: items
.iter()
.map(markdown_row)
.collect::<Vec<_>>()
.join("\n"),
}),
Shape::Timeline => Body::Timeline(TimelineData {
events: items
.iter()
.map(|it| TimelineEvent {
timestamp: it.created_at.as_deref().map(parse_timestamp).unwrap_or(0),
title: title_or_placeholder(it),
detail: Some(meta_label(it)),
status: None,
})
.collect(),
}),
_ => Body::TextBlock(TextBlockData {
lines: items
.iter()
.map(|it| format!("{} {}", meta_label(it), title_or_placeholder(it)))
.collect(),
}),
}
}
fn title_or_placeholder(it: &Item) -> String {
it.title
.as_deref()
.filter(|t| !t.is_empty())
.unwrap_or("(no title)")
.to_string()
}
fn meta_label(it: &Item) -> String {
let score = it.score.unwrap_or(0);
let comments = it.comment_count.unwrap_or(0);
format!("{score}pt {comments}c")
}
fn markdown_row(it: &Item) -> String {
let title = title_or_placeholder(it);
let meta = meta_label(it);
match link_for(it) {
Some(url) => format!("- **{meta}** [{title}]({url})"),
None => format!("- **{meta}** {title}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fetcher::FetchContext;
fn item(title: Option<&str>, score: Option<i64>, comments: Option<u64>) -> Item {
Item {
short_id: Some("abc123".into()),
title: title.map(String::from),
score,
comment_count: comments,
url: None,
comments_url: None,
short_id_url: None,
created_at: None,
}
}
fn ctx(shape: Shape, options: &str) -> FetchContext {
FetchContext {
shape: Some(shape),
options: Some(toml::from_str(options).unwrap()),
..FetchContext::default()
}
}
#[test]
fn options_default_to_none_for_each_field() {
let opts = Options::default();
assert!(opts.count.is_none());
assert!(opts.kind.is_none());
assert!(opts.tag.is_none());
}
#[test]
fn options_deserialize_full_set() {
let raw: toml::Value =
toml::from_str("count = 5\nkind = \"newest\"\ntag = \"rust\"").unwrap();
let opts: Options = raw.try_into().unwrap();
assert_eq!(opts.count, Some(5));
assert_eq!(opts.kind, Some(Kind::Newest));
assert_eq!(opts.tag.as_deref(), Some("rust"));
}
#[test]
fn options_reject_unknown_keys() {
let raw: toml::Value = toml::from_str("count = 3\nbogus = true").unwrap();
assert!(raw.try_into::<Options>().is_err());
}
#[test]
fn kind_endpoint_covers_every_variant() {
assert_eq!(Kind::Hottest.endpoint(), "hottest");
assert_eq!(Kind::Newest.endpoint(), "newest");
assert_eq!(Kind::Active.endpoint(), "active");
}
#[test]
fn fetcher_catalog_surface_matches_contract() {
let f = LobstersTopFetcher;
assert_eq!(f.name(), "lobsters_top");
assert_eq!(f.safety(), Safety::Safe);
assert_eq!(f.default_shape(), Shape::LinkedTextBlock);
assert_eq!(f.shapes(), SHAPES);
assert_eq!(f.option_schemas().len(), 3);
assert_eq!(f.option_schemas()[0].name, "count");
assert_eq!(f.option_schemas()[1].name, "kind");
assert_eq!(f.option_schemas()[2].name, "tag");
assert!(f.description().contains("Lobsters"));
}
#[test]
fn sample_body_supports_each_declared_shape() {
let f = LobstersTopFetcher;
for shape in SHAPES {
assert!(
f.sample_body(*shape).is_some(),
"missing sample for {shape:?}",
);
}
assert!(f.sample_body(Shape::Heatmap).is_none());
}
#[test]
fn cache_key_changes_with_shape_and_options() {
let f = LobstersTopFetcher;
let base = f.cache_key(&ctx(
Shape::LinkedTextBlock,
"count = 5\nkind = \"hottest\"",
));
let same = f.cache_key(&ctx(
Shape::LinkedTextBlock,
"count = 5\nkind = \"hottest\"",
));
let other_shape = f.cache_key(&ctx(Shape::Entries, "count = 5\nkind = \"hottest\""));
let other_kind = f.cache_key(&ctx(Shape::LinkedTextBlock, "count = 5\nkind = \"newest\""));
let with_tag = f.cache_key(&ctx(
Shape::LinkedTextBlock,
"count = 5\nkind = \"hottest\"\ntag = \"rust\"",
));
assert_eq!(base, same);
assert_ne!(base, other_shape);
assert_ne!(base, other_kind);
assert_ne!(base, with_tag);
}
#[test]
fn listing_path_uses_kind_endpoint_when_no_tag() {
assert_eq!(listing_path(Kind::Hottest, None), "/hottest.json");
assert_eq!(listing_path(Kind::Newest, None), "/newest.json");
assert_eq!(listing_path(Kind::Active, None), "/active.json");
}
#[test]
fn listing_path_routes_through_tag_when_set_ignoring_kind() {
assert_eq!(listing_path(Kind::Hottest, Some("rust")), "/t/rust.json");
assert_eq!(listing_path(Kind::Newest, Some("rust")), "/t/rust.json");
assert_eq!(listing_path(Kind::Active, Some("c++")), "/t/c++.json");
}
#[test]
fn normalize_tag_lowercases_and_trims() {
assert_eq!(normalize_tag(" Rust ").unwrap(), "rust");
assert_eq!(normalize_tag("C++").unwrap(), "c++");
assert_eq!(normalize_tag("a11y").unwrap(), "a11y");
}
#[test]
fn normalize_tag_rejects_empty() {
assert!(matches!(normalize_tag(""), Err(FetchError::Failed(_))));
assert!(matches!(normalize_tag(" "), Err(FetchError::Failed(_))));
}
#[test]
fn normalize_tag_rejects_overlength() {
let long = "a".repeat(TAG_MAX_LEN + 1);
let err = normalize_tag(&long).unwrap_err();
assert!(matches!(err, FetchError::Failed(msg) if msg.contains("too long")));
}
#[test]
fn normalize_tag_rejects_disallowed_chars() {
for raw in ["rust!", "rust/system", "rust space", "rust?", "rust#"] {
assert!(
matches!(normalize_tag(raw), Err(FetchError::Failed(_))),
"expected rejection for {raw:?}",
);
}
}
#[test]
fn text_uses_first_item_headline() {
let body = render_body(&[item(Some("hello"), Some(123), Some(45))], Shape::Text);
assert_eq!(
body,
Body::Text(TextData {
value: "123pt 45c hello".into(),
})
);
}
#[test]
fn text_block_line_includes_score_comments_and_title() {
let body = render_body(
&[item(Some("hello"), Some(123), Some(45))],
Shape::TextBlock,
);
assert_eq!(
body,
Body::TextBlock(TextBlockData {
lines: vec!["123pt 45c hello".into()],
})
);
}
#[test]
fn entries_use_title_as_key_and_meta_as_value() {
let body = render_body(&[item(Some("hello"), Some(7), None)], Shape::Entries);
assert_eq!(
body,
Body::Entries(EntriesData {
items: vec![Entry {
key: "hello".into(),
value: Some("7pt 0c".into()),
status: None,
}],
})
);
}
#[test]
fn bars_clamp_negative_score_to_zero() {
let body = render_body(&[item(Some("flagged"), Some(-3), Some(0))], Shape::Bars);
assert_eq!(
body,
Body::Bars(BarsData {
bars: vec![Bar {
label: "flagged".into(),
value: 0,
value_label: None
}],
})
);
}
#[test]
fn linked_text_block_prefers_story_url_when_present() {
let it = Item {
short_id: Some("abc".into()),
title: Some("show".into()),
score: Some(1),
comment_count: Some(0),
url: Some("https://example.com/post".into()),
comments_url: Some("https://lobste.rs/s/abc/show".into()),
short_id_url: Some("https://lobste.rs/s/abc".into()),
created_at: None,
};
let body = render_body(&[it], Shape::LinkedTextBlock);
assert_eq!(
body,
Body::LinkedTextBlock(LinkedTextBlockData {
items: vec![LinkedLine {
text: "1pt 0c show".into(),
url: Some("https://example.com/post".into()),
}],
})
);
}
#[test]
fn linked_text_block_url_falls_back_to_comments_when_story_url_empty() {
let it = Item {
short_id: Some("abc".into()),
title: Some("ask".into()),
score: Some(81),
comment_count: Some(50),
url: Some(String::new()),
comments_url: Some("https://lobste.rs/s/abc/ask".into()),
short_id_url: Some("https://lobste.rs/s/abc".into()),
created_at: None,
};
let body = render_body(&[it], Shape::LinkedTextBlock);
assert_eq!(
body,
Body::LinkedTextBlock(LinkedTextBlockData {
items: vec![LinkedLine {
text: "81pt 50c ask".into(),
url: Some("https://lobste.rs/s/abc/ask".into()),
}],
})
);
}
#[test]
fn linked_text_block_drops_url_when_every_link_field_is_missing() {
let it = Item {
short_id: None,
title: Some("orphan".into()),
score: Some(1),
comment_count: Some(0),
url: None,
comments_url: None,
short_id_url: None,
created_at: None,
};
let body = render_body(&[it], Shape::LinkedTextBlock);
assert_eq!(
body,
Body::LinkedTextBlock(LinkedTextBlockData {
items: vec![LinkedLine {
text: "1pt 0c orphan".into(),
url: None,
}],
})
);
}
#[test]
fn markdown_row_includes_meta_link_and_title() {
let it = Item {
short_id: Some("abc".into()),
title: Some("hello".into()),
score: Some(7),
comment_count: Some(2),
url: Some("https://example.com/h".into()),
comments_url: None,
short_id_url: None,
created_at: None,
};
assert_eq!(
markdown_row(&it),
"- **7pt 2c** [hello](https://example.com/h)",
);
}
#[test]
fn markdown_row_omits_link_syntax_when_no_url_resolvable() {
let it = Item {
short_id: None,
title: Some("hello".into()),
score: Some(0),
comment_count: Some(0),
url: None,
comments_url: None,
short_id_url: None,
created_at: None,
};
assert_eq!(markdown_row(&it), "- **0pt 0c** hello");
}
#[test]
fn timeline_uses_parsed_created_at_seconds() {
let it = Item {
short_id: Some("abc".into()),
title: Some("hello".into()),
score: Some(1),
comment_count: Some(2),
url: None,
comments_url: None,
short_id_url: None,
created_at: Some("2026-04-22T10:15:30Z".into()),
};
let Body::Timeline(TimelineData { events }) = render_body(&[it], Shape::Timeline) else {
panic!("expected Timeline body");
};
assert_eq!(events.len(), 1);
assert_eq!(events[0].timestamp, 1_776_852_930);
assert_eq!(events[0].title, "hello");
assert_eq!(events[0].detail.as_deref(), Some("1pt 2c"));
}
#[test]
fn timeline_falls_back_to_zero_when_created_at_missing_or_garbage() {
let it = item(Some("x"), Some(0), Some(0));
let Body::Timeline(TimelineData { events }) = render_body(&[it], Shape::Timeline) else {
panic!("expected Timeline body");
};
assert_eq!(events[0].timestamp, 0);
}
#[test]
fn missing_title_falls_back_to_placeholder() {
let body = render_body(&[item(None, Some(0), Some(0))], Shape::TextBlock);
assert_eq!(
body,
Body::TextBlock(TextBlockData {
lines: vec!["0pt 0c (no title)".into()],
})
);
}
#[test]
fn empty_items_renders_empty_bodies_for_each_shape() {
for shape in SHAPES {
let body = render_body(&[], *shape);
match body {
Body::Text(t) => assert!(t.value.is_empty()),
Body::TextBlock(t) => assert!(t.lines.is_empty()),
Body::LinkedTextBlock(t) => assert!(t.items.is_empty()),
Body::MarkdownTextBlock(t) => assert!(t.value.is_empty()),
Body::Entries(t) => assert!(t.items.is_empty()),
Body::Bars(t) => assert!(t.bars.is_empty()),
Body::Timeline(t) => assert!(t.events.is_empty()),
other => panic!("unexpected body for {shape:?}: {other:?}"),
}
}
}
#[test]
fn item_deserializes_from_lobsters_payload() {
let raw = r#"{"short_id":"abc","created_at":"2026-04-22T10:15:30Z","title":"hi","score":99,"flags":0,"comment_count":12,"url":"https://example.com","short_id_url":"https://lobste.rs/s/abc","comments_url":"https://lobste.rs/s/abc/hi"}"#;
let it: Item = serde_json::from_str(raw).unwrap();
assert_eq!(it.title.as_deref(), Some("hi"));
assert_eq!(it.score, Some(99));
assert_eq!(it.comment_count, Some(12));
assert_eq!(it.url.as_deref(), Some("https://example.com"));
}
#[test]
fn item_deserializes_when_optional_fields_are_missing() {
let raw = r#"{"short_id":"abc"}"#;
let it: Item = serde_json::from_str(raw).unwrap();
assert!(it.title.is_none());
assert!(it.score.is_none());
assert!(it.comment_count.is_none());
}
#[test]
fn count_clamps_extremes() {
assert_eq!(0u32.clamp(MIN_COUNT, MAX_COUNT), MIN_COUNT);
assert_eq!(999u32.clamp(MIN_COUNT, MAX_COUNT), MAX_COUNT);
assert_eq!(DEFAULT_COUNT.clamp(MIN_COUNT, MAX_COUNT), DEFAULT_COUNT);
}
#[tokio::test]
async fn fetch_rejects_unknown_options() {
let f = LobstersTopFetcher;
let err = f
.fetch(&ctx(Shape::LinkedTextBlock, "count = 3\nbogus = true"))
.await
.unwrap_err();
assert!(matches!(
err,
FetchError::Failed(msg) if msg.contains("unknown field `bogus`")
));
}
#[tokio::test]
async fn fetch_rejects_invalid_kind_value() {
let f = LobstersTopFetcher;
let err = f
.fetch(&ctx(Shape::LinkedTextBlock, "kind = \"frontpage\""))
.await
.unwrap_err();
assert!(matches!(
err,
FetchError::Failed(msg) if msg.contains("unknown variant `frontpage`")
));
}
#[tokio::test]
async fn fetch_rejects_disallowed_tag_chars() {
let f = LobstersTopFetcher;
let err = f
.fetch(&ctx(Shape::LinkedTextBlock, "tag = \"rust system\""))
.await
.unwrap_err();
assert!(matches!(
err,
FetchError::Failed(msg) if msg.contains("[a-z0-9_+-]")
));
}
#[tokio::test]
#[ignore]
async fn live_hottest_returns_at_least_one_story() {
let items = fetch_stories("/hottest.json", 3).await.unwrap();
assert!(!items.is_empty());
for it in &items {
eprintln!(
"{}pt {}c {}",
it.score.unwrap_or(0),
it.comment_count.unwrap_or(0),
it.title.as_deref().unwrap_or(""),
);
}
}
}