use async_trait::async_trait;
use serde::Deserialize;
use crate::fetcher::github::common::{parse_options, payload};
use crate::fetcher::{FetchContext, FetchError, Fetcher, Safety};
use crate::options::OptionSchema;
use crate::payload::{
Body, EntriesData, Entry, LinkedLine, LinkedTextBlockData, Payload, TextBlockData,
};
use crate::render::Shape;
use crate::samples;
use super::client::{SITE_BASE, fetch_listing};
use super::common::{
COMMENT_SHAPES as SHAPES, cache_key_for, network_unavailable_body, normalize_user,
normalized_count,
};
const DEFAULT_USER: &str = "spez";
const DEFAULT_COMMENT_CHARS: usize = 110;
const MIN_COMMENT_CHARS: usize = 32;
const MAX_COMMENT_CHARS: usize = 240;
const MISSING_BODY_PLACEHOLDER: &str = "(no text)";
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "user",
type_hint: "string",
required: false,
default: Some("\"spez\""),
description: "Reddit username (without `/u/` prefix).",
},
OptionSchema {
name: "count",
type_hint: "integer (1..=30)",
required: false,
default: Some("10"),
description: "Number of comments to display.",
},
OptionSchema {
name: "max_chars",
type_hint: "integer (32..=240)",
required: false,
default: Some("110"),
description: "Max preview length per comment line.",
},
];
pub struct RedditUserCommentsFetcher;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
#[serde(default)]
user: Option<String>,
#[serde(default)]
count: Option<u32>,
#[serde(default)]
max_chars: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct Comment {
#[serde(default)]
body: Option<String>,
#[serde(default)]
score: Option<i64>,
#[serde(default)]
subreddit: Option<String>,
#[serde(default)]
permalink: Option<String>,
}
#[async_trait]
impl Fetcher for RedditUserCommentsFetcher {
fn name(&self) -> &str {
"reddit_user_comments"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Recent comments by a single Reddit user, each prefixed with subreddit and score and linked to the comment's permalink. Use `reddit_user_posts` for that user's submissions or `reddit_subreddit_posts` for a subreddit's listing."
}
fn refresh_interval(&self) -> u64 {
60 * 30
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn cache_key(&self, ctx: &FetchContext) -> String {
cache_key_for(self.name(), ctx)
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
sample_comment_body(shape)
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let opts: Options = parse_options(ctx.options.as_ref()).map_err(FetchError::Failed)?;
let count = normalized_count(opts.count);
let shape = ctx.shape.unwrap_or(Shape::LinkedTextBlock);
let max_chars = opts
.max_chars
.unwrap_or(DEFAULT_COMMENT_CHARS)
.clamp(MIN_COMMENT_CHARS, MAX_COMMENT_CHARS);
let user = normalize_user(opts.user.as_deref().unwrap_or(DEFAULT_USER))?;
match fetch_user_comments(&user, count).await {
Ok(comments) => Ok(payload(render_comments(&comments, max_chars, shape))),
Err(err) => Ok(payload(network_unavailable_body(shape, &format!("{err}")))),
}
}
}
async fn fetch_user_comments(user: &str, count: usize) -> Result<Vec<Comment>, FetchError> {
let path = format!("/user/{user}/comments.json?limit={count}&raw_json=1");
fetch_listing(&path).await
}
fn render_comments(comments: &[Comment], max_chars: usize, shape: Shape) -> Body {
match shape {
Shape::Entries => Body::Entries(EntriesData {
items: comments
.iter()
.map(|comment| Entry {
key: truncate(comment_body(comment), max_chars),
value: Some(comment_meta(comment)),
status: None,
})
.collect(),
}),
Shape::LinkedTextBlock => Body::LinkedTextBlock(LinkedTextBlockData {
items: comments
.iter()
.map(|comment| LinkedLine {
text: format!(
"{} {}",
comment_meta(comment),
truncate(comment_body(comment), max_chars),
),
url: comment_link(comment),
})
.collect(),
}),
_ => Body::TextBlock(TextBlockData {
lines: comments
.iter()
.map(|comment| {
format!(
"{} {}",
comment_meta(comment),
truncate(comment_body(comment), max_chars),
)
})
.collect(),
}),
}
}
fn comment_body(comment: &Comment) -> &str {
comment.body.as_deref().unwrap_or(MISSING_BODY_PLACEHOLDER)
}
fn comment_meta(comment: &Comment) -> String {
let subreddit = comment
.subreddit
.as_deref()
.map(|name| format!("r/{name} · "))
.unwrap_or_default();
let score = comment.score.unwrap_or(0);
format!("{subreddit}{score}↑")
}
fn comment_link(comment: &Comment) -> Option<String> {
comment
.permalink
.as_deref()
.map(|path| format!("{SITE_BASE}{path}"))
}
fn truncate(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value.into();
}
let keep = max_chars.saturating_sub(1);
let clipped: String = value.chars().take(keep).collect();
format!("{clipped}…")
}
fn sample_comment_body(shape: Shape) -> Option<Body> {
Some(match shape {
Shape::LinkedTextBlock => samples::linked_text_block(&[
(
"r/rust · 42↑ This migration guide made the update painless...",
Some("https://www.reddit.com/r/rust/comments/abc123/topic/def456/"),
),
(
"r/programming · 18↑ +1 for clear release notes and rollback steps.",
Some("https://www.reddit.com/r/programming/comments/ghi789/topic/jkl012/"),
),
]),
Shape::TextBlock => samples::text_block(&[
"r/rust · 42↑ This migration guide made the update painless...",
"r/programming · 18↑ +1 for clear release notes and rollback steps.",
]),
Shape::Entries => samples::entries(&[
(
"This migration guide made the update painless...",
"r/rust · 42↑",
),
(
"+1 for clear release notes and rollback steps.",
"r/programming · 18↑",
),
]),
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn ctx(shape: Option<Shape>, options: Option<toml::Value>) -> FetchContext {
FetchContext {
widget_id: "reddit-user-comments".into(),
timeout: Duration::from_secs(1),
shape,
options,
..Default::default()
}
}
#[test]
fn options_parse_max_chars() {
let raw: toml::Value =
toml::from_str("user = \"spez\"\ncount = 5\nmax_chars = 80").unwrap();
let opts: Options = raw.try_into().unwrap();
assert_eq!(opts.user.as_deref(), Some("spez"));
assert_eq!(opts.count, Some(5));
assert_eq!(opts.max_chars, Some(80));
}
#[test]
fn options_reject_unknown_keys() {
let raw: toml::Value = toml::from_str("user = \"spez\"\nbogus = true").unwrap();
assert!(raw.try_into::<Options>().is_err());
}
#[test]
fn truncate_adds_ellipsis_when_needed() {
assert_eq!(truncate("abcdef", 4), "abc…");
assert_eq!(truncate("abc", 4), "abc");
}
#[test]
fn comment_meta_handles_missing_subreddit() {
let comment = Comment {
body: Some("hello".into()),
score: Some(7),
subreddit: None,
permalink: None,
};
assert_eq!(comment_meta(&comment), "7↑");
}
#[test]
fn comment_meta_supports_negative_score() {
let comment = Comment {
body: Some("hi".into()),
score: Some(-3),
subreddit: Some("rust".into()),
permalink: None,
};
assert_eq!(comment_meta(&comment), "r/rust · -3↑");
}
#[test]
fn render_comments_linked_text_block_uses_permalink() {
let body = render_comments(
&[Comment {
body: Some("Hello from a comment".into()),
score: Some(9),
subreddit: Some("rust".into()),
permalink: Some("/r/rust/comments/abc/post/def/".into()),
}],
80,
Shape::LinkedTextBlock,
);
assert!(matches!(&body, Body::LinkedTextBlock(_)));
if let Body::LinkedTextBlock(data) = body {
assert_eq!(data.items[0].text, "r/rust · 9↑ Hello from a comment");
assert_eq!(
data.items[0].url.as_deref(),
Some("https://www.reddit.com/r/rust/comments/abc/post/def/"),
);
}
}
#[test]
fn render_comments_falls_back_to_placeholder_when_body_missing() {
let body = render_comments(
&[Comment {
body: None,
score: Some(0),
subreddit: None,
permalink: None,
}],
80,
Shape::TextBlock,
);
assert!(matches!(&body, Body::TextBlock(_)));
if let Body::TextBlock(text) = body {
assert!(text.lines[0].contains(MISSING_BODY_PLACEHOLDER));
}
}
#[test]
fn render_comments_entries_truncate_long_body() {
let body = render_comments(
&[Comment {
body: Some("a".repeat(200)),
score: Some(1),
subreddit: Some("rust".into()),
permalink: None,
}],
32,
Shape::Entries,
);
assert!(matches!(&body, Body::Entries(_)));
if let Body::Entries(entries) = body {
assert!(entries.items[0].key.ends_with('…'));
assert_eq!(entries.items[0].key.chars().count(), 32);
assert_eq!(entries.items[0].value.as_deref(), Some("r/rust · 1↑"));
}
}
#[test]
fn render_comments_drops_url_when_permalink_missing() {
let body = render_comments(
&[Comment {
body: Some("hi".into()),
score: Some(1),
subreddit: Some("rust".into()),
permalink: None,
}],
80,
Shape::LinkedTextBlock,
);
assert!(matches!(&body, Body::LinkedTextBlock(_)));
if let Body::LinkedTextBlock(block) = body {
assert!(block.items[0].url.is_none());
}
}
#[test]
fn comment_link_prepends_site_base() {
let comment = Comment {
body: None,
score: None,
subreddit: None,
permalink: Some("/r/rust/comments/abc/x/".into()),
};
assert_eq!(
comment_link(&comment).as_deref(),
Some("https://www.reddit.com/r/rust/comments/abc/x/"),
);
}
#[test]
fn comment_deserializes_full_shape() {
let raw = r#"{
"body": "hello",
"score": -2,
"subreddit": "rust",
"permalink": "/r/rust/comments/abc/x/"
}"#;
let c: Comment = serde_json::from_str(raw).unwrap();
assert_eq!(c.body.as_deref(), Some("hello"));
assert_eq!(c.score, Some(-2));
assert_eq!(c.subreddit.as_deref(), Some("rust"));
assert_eq!(c.permalink.as_deref(), Some("/r/rust/comments/abc/x/"));
}
#[test]
fn truncate_preserves_multibyte_safely() {
let s = "あいうえおかきくけこ"; assert_eq!(truncate(s, 5).chars().count(), 5);
assert!(truncate(s, 5).ends_with('…'));
}
#[test]
fn sample_comment_body_returns_some_for_supported_shapes() {
for shape in [Shape::LinkedTextBlock, Shape::TextBlock, Shape::Entries] {
assert!(
sample_comment_body(shape).is_some(),
"expected sample for {shape:?}"
);
}
}
#[test]
fn sample_comment_body_returns_none_for_unsupported_shape() {
assert!(sample_comment_body(Shape::Ratio).is_none());
}
#[test]
fn options_default_to_none() {
let opts = Options::default();
assert!(opts.user.is_none());
assert!(opts.count.is_none());
assert!(opts.max_chars.is_none());
}
#[test]
fn fetcher_exposes_catalog_metadata_and_samples() {
let fetcher = RedditUserCommentsFetcher;
assert_eq!(fetcher.name(), "reddit_user_comments");
assert_eq!(fetcher.safety(), Safety::Safe);
assert_eq!(fetcher.default_shape(), Shape::LinkedTextBlock);
assert_eq!(fetcher.shapes(), SHAPES);
assert_eq!(
fetcher
.option_schemas()
.iter()
.map(|schema| schema.name)
.collect::<Vec<_>>(),
vec!["user", "count", "max_chars"]
);
assert!(matches!(
fetcher.sample_body(Shape::LinkedTextBlock),
Some(Body::LinkedTextBlock(_))
));
assert!(matches!(
fetcher.sample_body(Shape::TextBlock),
Some(Body::TextBlock(_))
));
assert!(matches!(
fetcher.sample_body(Shape::Entries),
Some(Body::Entries(_))
));
assert!(fetcher.sample_body(Shape::Ratio).is_none());
}
#[test]
fn cache_key_changes_with_shape_and_options() {
let fetcher = RedditUserCommentsFetcher;
let linked = fetcher.cache_key(&ctx(Some(Shape::LinkedTextBlock), None));
let entries = fetcher.cache_key(&ctx(Some(Shape::Entries), None));
let configured = fetcher.cache_key(&ctx(
Some(Shape::LinkedTextBlock),
Some(toml::from_str("user = \"spez\"\ncount = 5\nmax_chars = 80").unwrap()),
));
assert_ne!(linked, entries);
assert_ne!(linked, configured);
}
#[tokio::test]
async fn fetch_rejects_unknown_options_before_network() {
let err = RedditUserCommentsFetcher
.fetch(&ctx(
Some(Shape::TextBlock),
Some(toml::from_str("bogus = true").unwrap()),
))
.await
.unwrap_err();
assert!(format!("{err}").contains("unknown field"));
}
#[tokio::test]
async fn fetch_rejects_invalid_user_before_network() {
let err = RedditUserCommentsFetcher
.fetch(&ctx(
None,
Some(toml::from_str("user = \"/u/\"\ncount = 0\nmax_chars = 999").unwrap()),
))
.await
.unwrap_err();
assert!(format!("{err}").contains("user must not be empty"));
}
}