use crate::filter::Searchable;
use crate::{markdown, text};
use horologe::{DateTime, Utc, age::HasAge};
use serde::{Deserialize, Deserializer};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("Parsing error occurred: {0}")]
Parse(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
pub trait HasSubreddit {
fn subreddit(&self) -> &str;
}
#[derive(Debug)]
pub struct User {
about: About,
comments: Vec<Comment>,
submissions: Vec<Submission>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct About {
name: String,
id: String,
#[serde(deserialize_with = "from_timestamp_f64")]
created_utc: DateTime<Utc>,
link_karma: i64,
comment_karma: i64,
}
#[derive(Clone, Debug, Deserialize)]
#[allow(dead_code)]
pub struct Comment {
id: String,
name: String,
subreddit_id: String,
subreddit: String,
link_title: String,
link_id: String,
#[serde(deserialize_with = "from_timestamp_f64")]
created_utc: DateTime<Utc>,
body: String,
ups: i64,
downs: i64,
score: i64,
}
#[derive(Clone, Debug, Deserialize)]
#[allow(dead_code)]
pub struct Submission {
id: String,
name: String,
permalink: String,
author: String,
domain: String,
subreddit_id: String,
subreddit: String,
url: String,
title: String,
selftext: String,
#[serde(deserialize_with = "from_timestamp_f64")]
created_utc: DateTime<Utc>,
num_comments: u64,
ups: i64,
downs: i64,
score: i64,
}
impl User {
pub fn parse<S>(user_data: S, comment_data: S, post_data: S) -> Result<Self>
where
S: AsRef<str>,
{
let about = About::parse(user_data.as_ref())?;
let comments = Comment::parse(comment_data.as_ref())?;
let submissions = Submission::parse(post_data.as_ref())?;
Ok(User {
about,
comments,
submissions,
})
}
pub fn about(&self) -> &About {
&self.about
}
pub fn comments(&self) -> impl Iterator<Item = Comment> {
self.comments.clone().into_iter()
}
pub fn submissions(&self) -> impl Iterator<Item = Submission> {
self.submissions.clone().into_iter()
}
}
impl About {
fn parse(user_data: &str) -> Result<Self> {
Ok(serde_json::from_str(user_data).map(|wrapper: AboutResponse| wrapper.data)?)
}
pub fn created_utc(&self) -> DateTime<Utc> {
self.created_utc
}
pub fn link_karma(&self) -> i64 {
self.link_karma
}
pub fn comment_karma(&self) -> i64 {
self.comment_karma
}
}
impl Comment {
fn parse(comment_data: &str) -> Result<Vec<Self>> {
let json_object = serde_json::from_str(comment_data).map(
|comment_listing: ListingResponse<CommentResponse>| {
comment_listing
.data
.children
.into_iter()
.map(|comment_wrapper| comment_wrapper.data)
.collect()
},
)?;
Ok(json_object)
}
pub fn permalink(&self) -> String {
self.link_id.split("_").last().map(|link_id| {
let placeholder = String::from("z");
let subreddit = self.subreddit();
let comment_id = &self.id;
format!("https://www.reddit.com/r/{subreddit}/comments/{link_id}/{placeholder}/{comment_id}")
}).unwrap_or(String::from("?"))
}
pub fn link_title(&self) -> String {
text::convert_html_entities(&self.link_title).replace('\n', "")
}
pub fn score(&self) -> i64 {
self.score
}
pub fn body(&self) -> String {
markdown::parse(&self.body, textwrap::termwidth())
}
pub fn summarized_body(&self) -> String {
markdown::summarize(&self.body)
}
pub fn raw_body(&self) -> String {
textwrap::fill(
&text::convert_html_entities(&self.body),
textwrap::termwidth(),
)
}
pub fn markdown_body(&self) -> &str {
&self.body
}
}
impl HasAge for Comment {
fn created_utc(&self) -> DateTime<Utc> {
self.created_utc
}
}
impl HasSubreddit for Comment {
fn subreddit(&self) -> &str {
self.subreddit.trim()
}
}
impl Searchable for Comment {
fn search_text(&self) -> String {
self.body()
}
}
impl Submission {
fn parse(post_data: &str) -> Result<Vec<Self>> {
let json_object = serde_json::from_str(post_data).map(
|comment_listing: ListingResponse<SubmissionResponse>| {
comment_listing
.data
.children
.into_iter()
.map(|comment_wrapper| comment_wrapper.data)
.collect()
},
)?;
Ok(json_object)
}
pub fn is_self(&self) -> bool {
self.domain.starts_with("self.")
}
pub fn permalink(&self) -> String {
let path = &self.permalink;
format!("https://www.reddit.com{path}")
}
pub fn title(&self) -> String {
text::convert_html_entities(&self.title)
}
pub fn url(&self) -> &str {
&self.url
}
}
impl HasAge for Submission {
fn created_utc(&self) -> DateTime<Utc> {
self.created_utc
}
}
impl HasSubreddit for Submission {
fn subreddit(&self) -> &str {
&self.subreddit
}
}
impl Searchable for Submission {
fn search_text(&self) -> String {
String::from(&self.title)
}
}
fn from_timestamp_f64<'de, D>(deserializer: D) -> std::result::Result<DateTime<Utc>, D::Error>
where
D: Deserializer<'de>,
{
let ts_f64 = f64::deserialize(deserializer)?;
let ts = f64_to_i64(ts_f64)
.ok_or_else(|| serde::de::Error::custom(format!("Invalid Unix timestamp: {ts_f64}")))?;
DateTime::from_timestamp(ts, 0)
.ok_or_else(|| serde::de::Error::custom(format!("Invalid Unix timestamp: {ts}")))
}
fn f64_to_i64(n: f64) -> Option<i64> {
if n.is_finite() && n <= i64::MAX as f64 {
Some(n.trunc() as i64)
} else {
None
}
}
#[derive(Debug, Deserialize)]
struct AboutResponse {
data: About,
}
#[derive(Debug, Deserialize)]
struct ListingResponse<T> {
data: ChildrenResponse<T>,
}
#[derive(Debug, Deserialize)]
struct ChildrenResponse<T> {
children: Vec<T>,
}
#[derive(Debug, Deserialize)]
struct CommentResponse {
data: Comment,
}
#[derive(Debug, Deserialize)]
struct SubmissionResponse {
data: Submission,
}
#[cfg(test)]
mod tests {
mod about {
use super::super::*;
use crate::test_utils::load_data;
#[test]
fn it_cannot_parse_invalid_data() {
let about = About::parse(&load_data("about_404"));
assert!(about.is_err(), "should be Err, was {about:?}");
}
#[test]
fn it_can_parse_valid_data() {
let about = About::parse(&load_data("about_mipadi"));
assert!(about.is_ok());
}
#[test]
fn it_parses_fields() {
let about = About::parse(&load_data("about_mipadi")).unwrap();
let expected_created_at = DateTime::from_timestamp(1207004126, 0).unwrap();
assert_eq!(about.created_utc(), expected_created_at);
assert_eq!(
about.created_utc().to_rfc2822(),
"Mon, 31 Mar 2008 22:55:26 +0000"
);
assert_eq!(
about.created_utc().to_rfc3339(),
"2008-03-31T22:55:26+00:00"
);
assert_eq!(about.link_karma(), 11729);
assert_eq!(about.comment_karma(), 121995);
}
}
mod comments {
use super::super::*;
use crate::test_utils::{load_data, load_output};
use chrono::Local;
use pretty_assertions::assert_eq;
#[test]
fn it_cannot_parse_invalid_data() {
let comments = Comment::parse(&load_data("comments_404"));
assert!(comments.is_err(), "should be Err, was {comments:?}");
}
#[test]
fn it_can_parse_valid_data() {
let comments = Comment::parse(&load_data("comments_mipadi"));
assert!(comments.is_ok());
}
#[test]
fn it_can_parse_empty_data() {
let comments = Comment::parse(&load_data("comments_empty"));
assert!(comments.is_ok());
}
#[test]
fn it_parses_fields() {
let comments = Comment::parse(&load_data("comments_mipadi")).unwrap();
assert_eq!(comments.len(), 100);
let expected_link_title = "I dont want to play and we didn't even start";
let expected_body = "Honestly, min/maxing and system mastery is a big part of the \
Pathfinder community. It's a fairly crunchy system that draws in the sort of \
players who really like finding ways to exploit the rules. Supposedly some groups \
are more focused on roleplaying, but I have yet to meet a PF2 player in real life \
who gives a shit about pesky, whimsical things like _story_. If that's not your \
thing, you probably won't see eye to eye with the Pathfinder players you meet.\
\n\nI'm in a slightly similar boat right now: I don't care that much about \
min/maxing, but I put up with my Pathfinder friends because I really like our \
group and I like them as people well enough.";
let expected_created_utc = DateTime::from_timestamp(1743054429, 0).unwrap();
let comment = &comments[9];
assert_eq!(comment.id, "mjyuqdz");
assert_eq!(comment.name, "t1_mjyuqdz");
assert_eq!(comment.subreddit_id, "t5_2qh2s");
assert_eq!(comment.subreddit, "rpg");
assert_eq!(comment.link_title, expected_link_title);
assert_eq!(comment.link_id, "t3_1jktw0c");
assert_eq!(comment.created_utc, expected_created_utc);
assert_eq!(
comment.created_utc.to_rfc2822(),
"Thu, 27 Mar 2025 05:47:09 +0000"
);
assert_eq!(
comment.created_utc.to_rfc3339(),
"2025-03-27T05:47:09+00:00"
);
assert_eq!(comment.body, expected_body);
assert_eq!(comment.ups, -3);
assert_eq!(comment.downs, 0);
assert_eq!(comment.score, -3);
}
#[test]
fn it_returns_its_score() {
let comments = Comment::parse(&load_data("comments_mipadi")).unwrap();
let comment = &comments[9];
assert_eq!(comment.score(), -3);
}
#[test]
fn it_returns_its_subreddit() {
let comments = Comment::parse(&load_data("comments_mipadi")).unwrap();
let comment = &comments[0];
assert_eq!(comment.subreddit(), "cyphersystem");
}
#[test]
fn it_trims_whitespace_from_its_subreddit() {
let comments = Comment::parse(&load_data("comments_subreddit_whitespace")).unwrap();
let comment = &comments[0];
assert_eq!(comment.subreddit(), "LowSodiumHellDivers");
}
#[test]
fn it_returns_its_permalink() {
let comments = Comment::parse(&load_data("comments_mipadi")).unwrap();
let comment = &comments[0];
let expected = "https://www.reddit.com/r/cyphersystem/comments/1k1iixf/z/mnpd3zh";
let actual = comment.permalink();
assert_eq!(actual, expected);
}
#[test]
fn it_returns_its_link_title() {
let comments = Comment::parse(&load_data("comments_mipadi")).unwrap();
let comment = &comments[0];
assert_eq!(comment.link_title(), "Cypher System & ChatGPT");
}
#[test]
#[ignore]
fn it_trims_whitespace_from_link_titles() {
let expected = "this link title has a lot of whitespace";
let comments = Comment::parse(&load_data("comments_whitespace")).unwrap();
let comment = &comments[0];
let actual = comment.link_title();
assert_eq!(actual, expected);
}
#[test]
fn it_returns_its_body() {
let expected = load_output("comments_body");
let comments = Comment::parse(&load_data("comments_mipadi")).unwrap();
let comment = &comments[9];
let actual = comment.body();
assert_eq!(actual, expected, "\nleft:\n{actual}\n\nright:\n{expected}");
}
#[test]
fn it_converts_html_entities_in_its_body() {
let expected = load_output("comments_html_entities");
let comments = Comment::parse(&load_data("comments_mipadi")).unwrap();
let comment = &comments[3];
let actual = comment.body();
assert_eq!(actual, expected, "\nleft:\n{actual}\n\nright:\n{expected}");
}
#[test]
fn it_trims_whitespace_from_its_body() {
let expected = "No more whitespace!";
let comments = Comment::parse(&load_data("comments_whitespace")).unwrap();
let comment = &comments[0];
let actual = comment.body();
assert_eq!(actual, expected);
}
#[test]
fn it_returns_a_summarized_body() {
let expected = load_output("comments_body_summary");
let comments = Comment::parse(&load_data("comments_mipadi")).unwrap();
let comment = &comments[9];
let actual = comment.summarized_body();
assert_eq!(actual, expected, "\nleft:\n{actual}\n\nright:\n{expected}");
}
#[test]
fn it_returns_a_raw_body() {
let expected = load_output("comments_body_raw");
let comments = Comment::parse(&load_data("comments_mipadi")).unwrap();
let comment = &comments[2];
let actual = comment.raw_body();
assert_eq!(actual, expected, "\nleft:\n{actual}\n\nright:\n{expected}");
}
#[test]
fn it_returns_a_really_raw_body() {
let expected = load_output("comments_body_raw_markdown");
let comments = Comment::parse(&load_data("comments_mipadi")).unwrap();
let comment = &comments[2];
let actual = comment.markdown_body();
assert_eq!(actual, expected, "\nleft:\n{actual}\n\nright:\n{expected}");
}
#[test]
fn it_matches_a_fixed_string() {
let comments = Comment::parse(&load_data("comments_mipadi")).unwrap();
let comment = &comments[9];
let result = comment.matches("min/maxing");
assert!(result, "{result} != true");
}
#[test]
fn it_matches_a_fixed_string_case_insensitively() {
let comments = Comment::parse(&load_data("comments_mipadi")).unwrap();
let comment = &comments[9];
let result = comment.matches("Pathfinder");
assert!(result, "'Pathfinder' not found in text");
let result = comment.matches("pathfinder");
assert!(result, "'pathfinder' not found in text");
}
#[test]
fn it_matches_a_fixed_string_with_a_space() {
let comments = Comment::parse(&load_data("comments_mipadi")).unwrap();
let comment = &comments[9];
let result = comment.matches("see eye to eye");
assert!(result, "{result} != true");
}
#[test]
fn it_does_not_match_a_fixed_string() {
let comments = Comment::parse(&load_data("comments_mipadi")).unwrap();
let comment = &comments[9];
let result = comment.matches("D&D");
assert!(!result, "{} should not match 'D&D'", comment.search_text());
}
#[test]
fn it_returns_its_creation_time() {
let comments = Comment::parse(&load_data("comments_mipadi")).unwrap();
let comment = &comments[9];
let datetime = DateTime::parse_from_rfc3339("2025-03-27T05:47:09+00:00")
.unwrap()
.with_timezone(&Utc);
assert_eq!(comment.created_utc(), datetime);
}
#[test]
fn it_returns_its_creation_time_in_local_time() {
let comments = Comment::parse(&load_data("comments_mipadi")).unwrap();
let comment = &comments[9];
let datetime = DateTime::parse_from_rfc3339("2025-03-27T05:47:09+00:00")
.unwrap()
.with_timezone(&Local);
assert_eq!(comment.created_local(), datetime);
}
#[test]
fn it_returns_an_empty_collection() {
let comments = Comment::parse(&load_data("comments_empty")).unwrap();
assert!(comments.is_empty());
}
#[test]
fn it_removes_new_lines_in_comment_title() {
let comments = Comment::parse(&load_data("comments_title_newline")).unwrap();
let comment = &comments[0];
let expected = "[OC] I've always wondered if people know this shortcut exists, or if it's just largely unknown. I've been using it almost since the game's release and haven't seen anyone else use it yet.";
assert_eq!(comment.link_title(), expected);
}
}
mod submissions {
use super::super::*;
use crate::test_utils::load_data;
#[test]
fn it_cannot_parse_invalid_data() {
let submissions = Submission::parse(&load_data("submitted_404"));
assert!(submissions.is_err(), "should be Err, was {submissions:?}");
}
#[test]
fn it_can_parse_valid_data() {
let submissions = Submission::parse(&load_data("submitted_mipadi"));
assert!(submissions.is_ok());
}
#[test]
fn it_can_parse_empty_data() {
let submissions = Submission::parse(&load_data("submitted_empty"));
assert!(submissions.is_ok());
}
#[test]
fn it_parses_fields() {
let submissions = Submission::parse(&load_data("submitted_mipadi")).unwrap();
assert_eq!(submissions.len(), 100);
let submission = &submissions[0];
let expected_created_utc = DateTime::from_timestamp(1736196841, 0).unwrap();
assert_eq!(submission.id, "1hv9k9l");
assert_eq!(submission.name, "t3_1hv9k9l");
assert_eq!(
submission.permalink,
"/r/rpg/comments/1hv9k9l/collections_coinage_and_the_tyranny_of_fantasy/"
);
assert_eq!(submission.author, "mipadi");
assert_eq!(submission.domain, "acoup.blog");
assert_eq!(submission.subreddit_id, "t5_2qh2s");
assert_eq!(submission.subreddit, "rpg");
assert_eq!(
submission.url,
"https://acoup.blog/2025/01/03/collections-coinage-and-the-tyranny-of-fantasy-gold/"
);
assert_eq!(
submission.title,
"Collections: Coinage and the Tyranny of Fantasy \"Gold\""
);
assert_eq!(submission.selftext, "");
assert_eq!(submission.created_utc, expected_created_utc);
assert_eq!(
submission.created_utc.to_rfc2822(),
"Mon, 6 Jan 2025 20:54:01 +0000"
);
assert_eq!(
submission.created_utc.to_rfc3339(),
"2025-01-06T20:54:01+00:00"
);
assert_eq!(submission.num_comments, 22);
assert_eq!(submission.ups, 60);
assert_eq!(submission.downs, 0);
assert_eq!(submission.score, 60);
}
#[test]
fn it_parses_fields_of_self_posts() {
let submissions = Submission::parse(&load_data("submitted_mipadi")).unwrap();
assert_eq!(submissions.len(), 100);
let expected_selftext = "I have two types of technology upgrades available for my \
exosuit: items listed as _protection units_, and items listed as _protection \
upgrades_. The ones listed as upgrades have text that generally says something \
like \"an almost total rework of the <damage type> Protection, this upgrade \
brings unparalleled improvements to <damage type> Shielding and <damage \
type> Protection\", whereas the upgrade units give a percentage of resistance.\
\n\nShould I install both, or do I just need to install one or the other? For \
example:\n\n- I have a \"High-Energy Bio-Integrity Unit\" which is a _protection \
upgrade_, and I can build a \"Radiation Reflector\" which is a _protection unit_. \
Should I install both?\n- I have a \"Specialist De-Toxifier\" and I can build a \
\"Toxin Suppressor\". Should I install both?\n- I have a \"Carbon Sublimation \
Pump\" and I can build a \"Coolant Network\". Should I install both?\n- I have a \
\"Nitroged-Based Thermal Stabilizer\" and I can build a \"Thermic Layer\". Should \
I install both?\n\nAnd then for something similar but a little different:\n\n- I \
have a \"Deep Water Depth Protection\" which says it is an \"almost total rework \
of the Aeration Membrance\", and I can also build an Aeration Membrane. Will \
crafting and installing an Aeration Membrane bring any extra benefits?";
let expected_created_utc = DateTime::from_timestamp(1721503204, 0).unwrap();
let submission = &submissions[3];
assert_eq!(submission.id, "1e83c2w");
assert_eq!(submission.name, "t3_1e83c2w");
assert_eq!(
submission.permalink,
"/r/NoMansSkyTheGame/comments/1e83c2w/should_i_install_both_protection_upgrades_and/"
);
assert_eq!(submission.author, "mipadi");
assert_eq!(submission.domain, "self.NoMansSkyTheGame");
assert_eq!(submission.subreddit_id, "t5_325lr");
assert_eq!(submission.subreddit, "NoMansSkyTheGame");
assert_eq!(
submission.url,
"https://www.reddit.com/r/NoMansSkyTheGame/comments/1e83c2w/should_i_install_both_protection_upgrades_and/"
);
assert_eq!(
submission.title,
"Should I install both protection upgrades and protection units in an exosuit?"
);
assert_eq!(submission.selftext, expected_selftext);
assert_eq!(submission.created_utc, expected_created_utc);
assert_eq!(
submission.created_utc.to_rfc2822(),
"Sat, 20 Jul 2024 19:20:04 +0000"
);
assert_eq!(
submission.created_utc.to_rfc3339(),
"2024-07-20T19:20:04+00:00"
);
assert_eq!(submission.num_comments, 7);
assert_eq!(submission.ups, 1);
assert_eq!(submission.downs, 0);
assert_eq!(submission.score, 1);
}
#[test]
fn it_returns_its_subreddit() {
let submissions = Submission::parse(&load_data("submitted_mipadi")).unwrap();
let submission = &submissions[0];
assert_eq!(submission.subreddit(), "rpg");
}
#[test]
fn it_returns_its_permalink() {
let submissions = Submission::parse(&load_data("submitted_mipadi")).unwrap();
let submission = &submissions[0];
let expected = "https://www.reddit.com/r/rpg/comments/1hv9k9l/collections_coinage_and_the_tyranny_of_fantasy/";
assert_eq!(submission.permalink(), expected);
}
#[test]
fn it_returns_its_title() {
let submissions = Submission::parse(&load_data("submitted_mipadi")).unwrap();
let submission = &submissions[0];
let expected = "Collections: Coinage and the Tyranny of Fantasy \"Gold\"";
assert_eq!(submission.title(), expected);
}
#[test]
fn it_converts_html_entities_in_its_title() {
let submissions = Submission::parse(&load_data("submitted_mipadi")).unwrap();
let submission = &submissions[10];
let expected = "System Scorn: The Excesses of 3rd Edition Dungeons & Dragons";
assert_eq!(submission.title(), expected);
}
#[test]
fn it_returns_its_url() {
let submissions = Submission::parse(&load_data("submitted_mipadi")).unwrap();
let submission = &submissions[0];
let expected = "https://acoup.blog/2025/01/03/collections-coinage-and-the-tyranny-of-fantasy-gold/";
assert_eq!(submission.url(), expected);
}
#[test]
fn it_returns_true_if_it_is_a_self_post() {
let submissions = Submission::parse(&load_data("submitted_mipadi")).unwrap();
let submission = &submissions[3];
assert!(submission.is_self());
}
#[test]
fn it_returns_false_if_it_is_a_self_post() {
let submissions = Submission::parse(&load_data("submitted_mipadi")).unwrap();
let submission = &submissions[0];
assert!(!submission.is_self());
}
#[test]
fn it_returns_its_creation_time() {
let submissions = Submission::parse(&load_data("submitted_mipadi")).unwrap();
let submission = &submissions[0];
let expected = DateTime::parse_from_rfc3339("2025-01-06T20:54:01+00:00")
.expect("could not parse datetime string");
assert_eq!(submission.created_utc(), expected);
}
#[test]
fn it_returns_an_empty_collection() {
let submissions = Submission::parse(&load_data("submitted_empty")).unwrap();
assert!(submissions.is_empty());
}
}
}