Side A fixes real clippy warnings, restores a lost test (missing #[test] attribute meant it never ran), and applies small correctness cleanups integrated into the existing codebase. Side B adds a standalone, unintegrated reddit.rs file (not wired into any module or Cargo.toml shown) that appears unrelated to the project's actual domain, with a vague commit message ('nice') indicating low deliberate value.
constitution · epochs · watch · epoch 3
c_88200cfa5c4d (tommy-mor) vs c_b8e80699547c (tommy-mor)
download prompt · raw event · cmp_700ce8500da885
council reasoning
Side B adds a full Reddit API client (~420 lines) with rate limiting, typed serde models, and error handling—substantial new capability—whereas Side A is mostly mechanical clippy/style nits (let-pattern rewrites, unnecessary refs, map_or→is_some_and) plus a missing #[test] attribute and a dead_code allow. Line count favors B because the change is real surface area, not formatting churn.
Side B adds a substantial new Reddit API client with rate limiting, request methods for multiple Reddit endpoints, structured response types, custom deserialization, and helper APIs, representing a significant new capability. Side A is almost entirely Clippy-warning cleanup and minor test maintenance, with its only functional change being adding a missing #[test] attribute and allowing dead code in shared test helpers; these are useful but comparatively small and mostly non-functional.
sides
A — c_88200cfa5c4d (tommy-mor)
message
[10c9caac] Fix all workspace clippy warnings. Wire up a missing ui_action test, allow dead code in shared integration helpers, and apply small clippy cleanups across server and types. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/server/src/dsl.rs b/server/src/dsl.rs
index a45c5b33e10d0d2ea48c7313061cfa1e6430bcfc..faa8aac6616bc6ea2b102d08ae999c01a716ef6e 100644
--- a/server/src/dsl.rs
+++ b/server/src/dsl.rs
@@ -927,9 +927,7 @@ mod tests {
#[test]
fn parse_vote_rejects_zero_zero_ratio() {
let err = parse_full("{tie placeholder}\n~/a 0:0 ~/b").unwrap_err();
- let msg = match err {
- DslError::Parse(m) => m,
- };
+ let DslError::Parse(msg) = err;
assert!(
msg.contains("0:0"),
"expected 0:0 rejection message, got: {msg}"
diff --git a/server/src/html/garden/tests.rs b/server/src/html/garden/tests.rs
index c2036fca7752aef63d260e36cfe9649f63b5c550..0a1be1290fdc93197ca191197a473840bb4decbc 100644
--- a/server/src/html/garden/tests.rs
+++ b/server/src/html/garden/tests.rs
@@ -346,7 +346,7 @@ fn vote_compare_item_card_renders_github_import_markup() {
"headline": "#1 Compare card",
"sublines": ["State: open"],
});
- let body = format!("```slug-github-card\n{}\n```", json.to_string());
+ let body = format!("```slug-github-card\n{json}\n```");
let html = vote_compare_item_card(
&nav,
&item,
diff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs
index 2589a2cc00bb7b18cd19ebcbb938083122d6921d..5e4131dd346a6f80bfe091a9b0cf7902721bc47f 100644
--- a/server/src/html/ui_action.rs
+++ b/server/src/html/ui_action.rs
@@ -246,6 +246,7 @@ mod tests {
);
}
+ #[test]
fn set_new_thread_compose_expanded_true() {
let template = serde_json::json!({
"action": "set_new_thread_compose_expanded",
diff --git a/server/src/offline.rs b/server/src/offline.rs
index 2db6f67e22e00a24ce673d13095644dd9fa9342d..93d4d5ee55449da644f732bc0ba007ee4c2c7079 100644
--- a/server/src/offline.rs
+++ b/server/src/offline.rs
@@ -167,7 +167,7 @@ fn rankings_for_simulated(
.iter()
.map(|parent| {
let scoped_content = simulated
- .content_for_scope(&scope)
+ .content_for_scope(scope)
.unwrap_or_else(|| simulated.public());
let scoped = build_children_rankings(scoped_content, parent);
let components: Vec<RankComponent> = scoped
@@ -254,7 +254,9 @@ fn ingest_parse_error(raw: &str) -> Option<String> {
dsl::parse_full(raw).err().map(|e| e.to_string())
}
-fn load_events_from_jsonl(path: &Path) -> Result<(usize, Vec<(usize, Event)>, Vec<BadJsonLine>), std::io::Error> {
+type JsonlEventsLoad = Result<(usize, Vec<(usize, Event)>, Vec<BadJsonLine>), std::io::Error>;
+
+fn load_events_from_jsonl(path: &Path) -> JsonlEventsLoad {
let text = std::fs::read_to_string(path)?;
let total_lines = text.lines().count();
let mut events = Vec::new();
diff --git a/server/src/resolvers/github.rs b/server/src/resolvers/github.rs
index 30dd4cdac96de0e3da4fa03fdf82f91bed73bfe0..3cec5e8b7249a4597c378890cc9e6125104e76c3 100644
--- a/server/src/resolvers/github.rs
+++ b/server/src/resolvers/github.rs
@@ -749,6 +749,6 @@ mod tests {
GithubImportKind::Issue,
);
assert!(card.sublines.iter().any(|l| l.contains("@octo")));
- assert_eq!(card.excerpt.as_deref(), Some("The issue body.").as_deref());
+ assert_eq!(card.excerpt.as_deref(), Some("The issue body."));
}
}
diff --git a/server/tests/basic.rs b/server/tests/basic.rs
index 31a35251a8abd6f4a48c1d7782b6985f621375e1..9d83e7a97e2c7494790db17c4b5b30c181705026 100644
--- a/server/tests/basic.rs
+++ b/server/tests/basic.rs
@@ -337,7 +337,7 @@ async fn event_log_handles_corrupt_lines() {
.unwrap();
// Add empty line.
- writeln!(f, "").unwrap();
+ writeln!(f).unwrap();
let (loaded, bad) = log.load_all().await.unwrap();
assert_eq!(loaded.len(), 2);
@@ -511,9 +511,7 @@ fn dsl_parse_rejects_zero_zero_vote_ratio() {
"~/t/a {a}\n~/t/b {b}\n{zero}\n~/t/a 0:0 ~/t/b\n",
)
.expect_err("0:0 vote must be rejected by the parser");
- let msg = match err {
- slugsocial_server::dsl::DslError::Parse(m) => m,
- };
+ let slugsocial_server::dsl::DslError::Parse(msg) = err;
assert!(
msg.contains("0:0"),
"expected message about invalid 0:0 ratio, got: {msg}"
@@ -904,7 +902,7 @@ fn posts_by_actor_indexes_and_profile_visibility() {
fn feed_query(state: &ReducerState, cutoff: i64, limit: usize) -> (usize, Vec<String>) {
let matching: Vec<&str> = state.ingests_ordered.iter().rev()
.map(|id| id.as_str())
- .take_while(|id| state.ingests_by_id.get(*id).map_or(false, |ing| ing.ts > cutoff))
+ .take_while(|id| state.ingests_by_id.get(*id).is_some_and(|ing| ing.ts > cutoff))
.filter(|id| {
state.ingests_by_id.get(*id).is_some_and(|ing| {
let scope = slugsocial_server::reducer::scope_from_room_wire(&ing.room_id);
diff --git a/server/tests/integration_health.rs b/server/tests/integration_health.rs
index 481222d8c48c44fcfb7e9ba26cf7644b5c26d5f4..351aae6b0e9738021886a076ee08fab5cf5a0001 100644
--- a/server/tests/integration_health.rs
+++ b/server/tests/integration_health.rs
@@ -7,7 +7,7 @@ async fn test_healthz() {
let (addr, _tmp, _log, _handle) = create_test_server().await;
let client = reqwest::Client::new();
let response = client
- .get(&format!("http://{}/healthz", addr))
+ .get(format!("http://{}/healthz", addr))
.send()
.await
.unwrap();
diff --git a/server/tests/integration_rpc.rs b/server/tests/integration_rpc.rs
index ccd89a02594bd2a8e7047edafca04b0e54384139..ec446d94a38c8d6961d2135dd031da7ab2a39b48 100644
--- a/server/tests/integration_rpc.rs
+++ b/server/tests/integration_rpc.rs
@@ -440,7 +440,6 @@ async fn test_rank_history() {
let bearer = test_bearer();
let ingest = |delegate: &str, text: &str| {
let client = client.clone();
- let addr = addr;
let bearer = bearer.clone();
let text = text.to_string();
let delegate = delegate.to_string();
diff --git a/server/tests/support/mod.rs b/server/tests/support/mod.rs
index 84dc1d68b6c1e1fdfdbd0757139c06c3daf1e9a6..4a620eaa875e7a1145f2a4e82cc4ff2be21d5345 100644
--- a/server/tests/support/mod.rs
+++ b/server/tests/support/mod.rs
@@ -1,3 +1,6 @@
+//! Shared helpers for integration tests; each test binary uses a different subset.
+#![allow(dead_code)]
+
use sha2::{Digest, Sha256};
use slugsocial_server::{
event_log::EventLog,
diff --git a/types/src/paths.rs b/types/src/paths.rs
index ebc299c5e1af456c3e4b7fa45ed9fb23313e88c4..d2e799942b98dd7342764475deb17903fb60c600 100644
--- a/types/src/paths.rs
+++ b/types/src/paths.rs
@@ -411,7 +411,7 @@ mod tests {
#[test]
fn garden_item_url_deref_to_str() {
let g = GardenItemUrl::from_storage_str("https://slug.social/~/x", "public");
- let s: &str = &*g;
+ let s: &str = &g;
assert_eq!(s, "https://slug.social/~/x");
}
B — c_b8e80699547c (tommy-mor)
message
[432e1450] nice
diff preview
diff --git a/reddit.rs b/reddit.rs
new file mode 100644
index 0000000000000000000000000000000000000000..da27f6f76482dd27d44cad9d7b844972bf042e64
--- /dev/null
+++ b/reddit.rs
@@ -0,0 +1,420 @@
+use governor::{Quota, RateLimiter, Jitter};
+use nonzero_ext::nonzero;
+use reqwest::{Client, StatusCode};
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+use std::time::Duration;
+use anyhow::{Result, Context, anyhow};
+
+/// Reddit API client with built-in rate limiting
+pub struct RedditClient {
+ client: Client,
+ limiter: Arc<governor::DefaultDirectRateLimiter>,
+ user_agent: String,
+}
+
+impl RedditClient {
+ /// Create a new Reddit client with rate limiting
+ /// Reddit API allows 60 requests per minute for OAuth2 authenticated apps
+ /// We'll be conservative and use 50 requests per minute
+ pub fn new() -> Self {
+ // Create rate limiter: 50 requests per minute
+ let quota = Quota::per_minute(nonzero!(50u32));
+ let limiter = Arc::new(RateLimiter::direct(quota));
+
+ // Create HTTP client with timeout
+ let client = Client::builder()
+ .timeout(Duration::from_secs(30))
+ .build()
+ .expect("Failed to create HTTP client");
+
+ // Reddit requires a unique user agent
+ let user_agent = format!(
+ "rust:sorter:v{} (by /u/hourLong_arnould)",
+ env!("CARGO_PKG_VERSION")
+ );
+
+ Self {
+ client,
+ limiter,
+ user_agent,
+ }
+ }
+
+ /// Fetch hot posts from a subreddit
+ pub async fn get_subreddit_hot(&self, subreddit: &str, limit: usize) -> Result<RedditListingResponse> {
+ // Wait for rate limiter
+ self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+
+ let url = format!("https://www.reddit.com/r/{}/hot.json?limit={}", subreddit, limit);
+
+ let response = self.client
+ .get(&url)
+ .header("User-Agent", &self.user_agent)
+ .send()
+ .await
+ .context("Failed to send request to Reddit")?;
+
+ self.handle_response(response).await
+ }
+
+ /// Fetch top posts from a subreddit
+ pub async fn get_subreddit_top(&self, subreddit: &str, limit: usize, time_period: &str) -> Result<RedditListingResponse> {
+ self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+
+ let url = format!(
+ "https://www.reddit.com/r/{}/top.json?limit={}&t={}",
+ subreddit, limit, time_period
+ );
+
+ let response = self.client
+ .get(&url)
+ .header("User-Agent", &self.user_agent)
+ .send()
+ .await
+ .context("Failed to send request to Reddit")?;
+
+ self.handle_response(response).await
+ }
+
+ /// Fetch new posts from a subreddit
+ pub async fn get_subreddit_new(&self, subreddit: &str, limit: usize) -> Result<RedditListingResponse> {
+ self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+
+ let url = format!("https://www.reddit.com/r/{}/new.json?limit={}", subreddit, limit);
+
+ let response = self.client
+ .get(&url)
+ .header("User-Agent", &self.user_agent)
+ .send()
+ .await
+ .context("Failed to send request to Reddit")?;
+
+ self.handle_response(response).await
+ }
+
+ /// Fetch a specific post and its comments
+ pub async fn get_post(&self, subreddit: &str, post_id: &str) -> Result<Vec<RedditListingResponse>> {
+ self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+
+ let url = format!(
+ "https://www.reddit.com/r/{}/comments/{}.json",
+ subreddit, post_id
+ );
+
+ let response = self.client
+ .get(&url)
+ .header("User-Agent", &self.user_agent)
+ .send()
+ .await
+ .context("Failed to send request to Reddit")?;
+
+ match response.status() {
+ StatusCode::OK => {
+ let listings: Vec<RedditListingResponse> = response
+ .json()
+ .await
+ .context("Failed to parse Reddit response")?;
+ Ok(listings)
+ }
+ StatusCode::TOO_MANY_REQUESTS => {
+ Err(anyhow!("Reddit rate limit exceeded. Please try again later."))
+ }
+ StatusCode::NOT_FOUND => {
+ Err(anyhow!("Post not found: r/{}/comments/{}", subreddit, post_id))
+ }
+ status => {
+ Err(anyhow!("Reddit API error: {}", status))
+ }
+ }
+ }
+
+ /// Fetch user profile (posts and comments)
+ pub async fn get_user_profile(&self, username: &str, content_type: &str, limit: usize) -> Result<RedditListingResponse> {
+ self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+
+ let url = format!(
+ "https://www.reddit.com/user/{}/{}.json?limit={}",
+ username, content_type, limit
+ );
+
+ let response = self.client
+ .get(&url)
+ .header("User-Agent", &self.user_agent)
+ .send()
+ .await
+ .context("Failed to send request to Reddit")?;
+
+ self.handle_response(response).await
+ }
+
+ /// Handle Reddit API response with proper error checking
+ async fn handle_response(&self, response: reqwest::Response) -> Result<RedditListingResponse> {
+ match response.status() {
+ StatusCode::OK => {
+ let listing: RedditListingResponse = response
+ .json()
+ .await
+ .context("Failed to parse Reddit response")?;
+ Ok(listing)
+ }
+ StatusCode::TOO_MANY_REQUESTS => {
+ Err(anyhow!("Reddit rate limit exceeded. Please try again later."))
+ }
+ StatusCode::NOT_FOUND => {
+ Err(anyhow!("Reddit resource not found"))
+ }
+ StatusCode::FORBIDDEN => {
+ Err(anyhow!("Access forbidden. The subreddit may be private."))
+ }
+ status => {
+ Err(anyhow!("Reddit API error: {}", status))
+ }
+ }
+ }
+}
+
+// Reddit API response types
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditListingResponse {
+ pub kind: String,
+ pub data: RedditListingData,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditListingData {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub after: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub before: Option<String>,
+ pub children: Vec<RedditThing>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub modhash: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditThing {
+ pub kind: String,
+ pub data: RedditThingData,
+}
+
+/// Reddit's "Thing" data can be either a post, comment, or other types
+/// We use untagged enum to handle different data structures
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum RedditThingData {
+ Post(RedditPost),
+ Comment(RedditComment),
+ // For things we don't care about yet (like "more" comments)
+ Other(serde_json::Value),
+}
+
+/// Reddit post data with serde handling all the parsing
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditPost {
+ pub id: String,
+ pub name: String, // Full name (t3_xxx)
+ #[serde(default = "default_untitled")]
+ pub title: String,
+ #[serde(default = "default_deleted_user")]
+ pub author: String,
+ pub subreddit: String,
+ #[serde(default)]
+ pub url: String,
+ #[serde(default)]
+ pub permalink: String,
+ #[serde(default, deserialize_with = "deserialize_selftext")]
+ pub selftext: Option<String>,
+ #[serde(default)]
+ pub score: i64,
+ #[serde(default)]
+ pub num_comments: i64,
+ #[serde(default)]
+ pub created_utc: f64,
+ #[serde(default, deserialize_with = "deserialize_thumbnail")]
+ pub thumbnail: Option<String>,
+ #[serde(default)]
+ pub is_video: bool,
+ #[serde(default)]
+ pub is_self: bool,
+ // Additional useful fields
+ #[serde(default)]
+ pub ups: i64,
+ #[serde(default)]
+ pub downs: i64,
+ #[serde(default)]
+ pub upvote_ratio: f64,
+ #[serde(default)]
+ pub over_18: bool,
+ #[serde(default)]
+ pub spoiler: bool,
+ #[serde(default)]
+ pub stickied: bool,
+ #[serde(default)]
+ pub locked: bool,
+ #[serde(default)]
+ pub distinguished: Option<String>,
+}
+
+/// Reddit comment data
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditComment {
+ pub id: String,
+ pub name: String, // Full name (t1_xxx)
+ #[serde(default = "default_deleted_user")]
+ pub author: String,
+ #[serde(default)]
+ pub body: String,
+ #[serde(default)]
+ pub score: i64,
+ #[serde(default)]
+ pub created_utc: f64,
+ #[serde(default)]
+ pub parent_id: String,
+ #[serde(default)]
+ pub permalink: String,
+ #[serde(default)]
+ pub depth: i32,
+ // Additional useful fields
+ #[serde(default)]
+ pub ups: i64,
+ #[serde(default)]
+ pub downs: i64,
+ #[serde(default)]
+ pub edited: RedditEditStatus,
+ #[serde(default)]
+ pub stickied: bool,
+ #[serde(default)]
+ pub distinguished: Option<String>,
+ #[serde(default)]
+ pub is_submitter: bool,
+ #[serde(default)]
+ pub collapsed: bool,
+ #[serde(default)]
+ pub controversiality: i32,
+}
+
+/// Reddit's edit status - can be false or a timestamp
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum RedditEditStatus {
+ NotEdited(bool),
+ EditedAt(f64),
+}
+
+impl Default for RedditEditStatus {
+ fn default() -> Self {
+ RedditEditStatus::NotEdited(false)
+ }
+}
+
+// Helper functions for serde defaults
+fn default_untitled() -> String {
+ "Untitled".to_string()
+}
+
+fn default_deleted_user() -> String {
+ "[deleted]".to_string()
+}
+
+// Custom deserializer for selftext (empty strings should be None)
+fn deserialize_selftext<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ let s: Option<String> = Option::deserialize(deserializer)?;
+ Ok(s.filter(|text| !text.is_empty()))
+}
+
+// Custom deserializer for thumbnail (filter out "self", "default", empty)
+fn deserialize_thumbnail<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ let s: Option<String> = Option::deserialize(deserializer)?;
+ Ok(s.filter(|thumb| {
+ !thumb.is_empty() && thumb != "self" && thumb != "default" && thumb != "nsfw" && thumb != "spoiler"
+ }))
+}
+
+// Helper methods for extracting typed data from Things
+impl RedditThing {
+ /// Try to get this Thing as a Post
+ pub fn as_post(&self) -> Option<&RedditPost> {
+ if self.kind != "t3" {
+ return None;
+ }
+ match &self.data {
+ RedditThingData::Post(post) => Some(post),
+ _ => None,
+ }
+ }
+
+ /// Try to get this Thing as a Comment
+ pub fn as_comment(&self) -> Option<&RedditComment> {
+ if self.kind != "t1" {
+ return None;
+ }
+ match &self.data {
+ RedditThingData::Comment(comment) => Some(comment),
+ _ => None,
+ }
+ }
+
+ /// Check if th
… preview truncated; 1,666 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.