pub mod signals;
mod engine;
mod weights;
pub use engine::ScoringEngine;
pub use weights::{
find_matched_keywords, format_follower_count, format_tweet_age, format_tweet_age_at,
truncate_text,
};
use crate::config::ScoringConfig;
#[derive(Debug, Clone)]
pub struct TweetData {
pub text: String,
pub created_at: String,
pub likes: u64,
pub retweets: u64,
pub replies: u64,
pub author_username: String,
pub author_followers: u64,
#[allow(dead_code)]
pub has_media: bool,
#[allow(dead_code)]
pub is_quote_tweet: bool,
}
#[derive(Debug, Clone)]
pub struct TweetScore {
pub total: f32,
pub keyword_relevance: f32,
pub follower: f32,
pub recency: f32,
pub engagement: f32,
pub reply_count: f32,
pub content_type: f32,
pub meets_threshold: bool,
}
impl TweetScore {
pub fn format_breakdown(
&self,
config: &ScoringConfig,
tweet: &TweetData,
matched_keywords: &[String],
) -> String {
let truncated = truncate_text(&tweet.text, 50);
let formatted_followers = format_follower_count(tweet.author_followers);
let age = format_tweet_age(&tweet.created_at);
let matched_list = if matched_keywords.is_empty() {
"none".to_string()
} else {
matched_keywords.join(", ")
};
let total_engagement = tweet.likes + tweet.retweets + tweet.replies;
let followers_for_rate = tweet.author_followers.max(1) as f64;
let rate_pct = (total_engagement as f64 / followers_for_rate) * 100.0;
let verdict = if self.meets_threshold {
"REPLY"
} else {
"SKIP"
};
let reply_count_display = tweet.replies;
format!(
"Tweet: \"{}\" by @{} ({} followers)\n\
Score: {:.0}/100\n\
\x20 Keyword relevance: {:.0}/{} (matched: {})\n\
\x20 Author reach: {:.0}/{} ({} followers, bell curve)\n\
\x20 Recency: {:.0}/{} (posted {} ago)\n\
\x20 Engagement rate: {:.0}/{} ({:.1}% engagement vs 1.5% baseline)\n\
\x20 Reply count: {:.0}/{} ({} existing replies)\n\
\x20 Content type: {:.0}/{} ({})\n\
Verdict: {} (threshold: {})",
truncated,
tweet.author_username,
formatted_followers,
self.total,
self.keyword_relevance,
config.keyword_relevance_max as u32,
matched_list,
self.follower,
config.follower_count_max as u32,
formatted_followers,
self.recency,
config.recency_max as u32,
age,
self.engagement,
config.engagement_rate_max as u32,
rate_pct,
self.reply_count,
config.reply_count_max as u32,
reply_count_display,
self.content_type,
config.content_type_max as u32,
if tweet.has_media || tweet.is_quote_tweet {
"media/quote"
} else {
"text-only"
},
verdict,
config.threshold,
)
}
}
impl std::fmt::Display for TweetScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Score: {:.0}/100 [kw:{:.0} fol:{:.0} rec:{:.0} eng:{:.0} rep:{:.0} ct:{:.0}] {}",
self.total,
self.keyword_relevance,
self.follower,
self.recency,
self.engagement,
self.reply_count,
self.content_type,
if self.meets_threshold {
"REPLY"
} else {
"SKIP"
}
)
}
}
#[cfg(test)]
mod signals_tests;
#[cfg(test)]
mod tests;