mod generator;
mod publisher;
mod scheduler;
#[cfg(test)]
mod tests_guardrails;
use super::loop_helpers::{
ContentSafety, ContentStorage, ThreadPoster, TopicScorer, TweetGenerator,
};
use std::sync::Arc;
pub(super) const EXPLOIT_RATIO: f64 = 0.8;
pub struct ContentLoop {
pub(super) generator: Arc<dyn TweetGenerator>,
pub(super) safety: Arc<dyn ContentSafety>,
pub(super) storage: Arc<dyn ContentStorage>,
pub(super) topic_scorer: Option<Arc<dyn TopicScorer>>,
pub(super) thread_poster: Option<Arc<dyn ThreadPoster>>,
pub(super) topics: Vec<String>,
pub(super) post_window_secs: u64,
pub(super) dry_run: bool,
}
#[derive(Debug)]
pub enum ContentResult {
Posted { topic: String, content: String },
TooSoon { elapsed_secs: u64, window_secs: u64 },
RateLimited,
NoTopics,
Failed { error: String },
}
impl ContentLoop {
pub fn new(
generator: Arc<dyn TweetGenerator>,
safety: Arc<dyn ContentSafety>,
storage: Arc<dyn ContentStorage>,
topics: Vec<String>,
post_window_secs: u64,
dry_run: bool,
) -> Self {
Self {
generator,
safety,
storage,
topic_scorer: None,
thread_poster: None,
topics,
post_window_secs,
dry_run,
}
}
pub fn with_topic_scorer(mut self, scorer: Arc<dyn TopicScorer>) -> Self {
self.topic_scorer = Some(scorer);
self
}
pub fn with_thread_poster(mut self, poster: Arc<dyn ThreadPoster>) -> Self {
self.thread_poster = Some(poster);
self
}
}
#[cfg(test)]
pub(super) mod test_mocks {
use crate::automation::loop_helpers::{
ContentSafety, ContentStorage, TopicScorer, TweetGenerator,
};
use crate::automation::ContentLoopError;
use std::sync::Mutex;
pub struct MockGenerator {
pub response: String,
}
#[async_trait::async_trait]
impl TweetGenerator for MockGenerator {
async fn generate_tweet(&self, _topic: &str) -> Result<String, ContentLoopError> {
Ok(self.response.clone())
}
}
pub struct OverlongGenerator {
pub first_response: String,
pub retry_response: String,
pub call_count: Mutex<usize>,
}
#[async_trait::async_trait]
impl TweetGenerator for OverlongGenerator {
async fn generate_tweet(&self, _topic: &str) -> Result<String, ContentLoopError> {
let mut count = self.call_count.lock().expect("lock");
*count += 1;
if *count == 1 {
Ok(self.first_response.clone())
} else {
Ok(self.retry_response.clone())
}
}
}
pub struct FailingGenerator;
#[async_trait::async_trait]
impl TweetGenerator for FailingGenerator {
async fn generate_tweet(&self, _topic: &str) -> Result<String, ContentLoopError> {
Err(ContentLoopError::LlmFailure(
"model unavailable".to_string(),
))
}
}
pub struct MockSafety {
pub can_tweet: bool,
pub can_thread: bool,
}
#[async_trait::async_trait]
impl ContentSafety for MockSafety {
async fn can_post_tweet(&self) -> bool {
self.can_tweet
}
async fn can_post_thread(&self) -> bool {
self.can_thread
}
}
pub struct MockStorage {
pub last_tweet: Mutex<Option<chrono::DateTime<chrono::Utc>>>,
pub posted_tweets: Mutex<Vec<(String, String)>>,
pub actions: Mutex<Vec<(String, String, String)>>,
}
impl MockStorage {
pub fn new(last_tweet: Option<chrono::DateTime<chrono::Utc>>) -> Self {
Self {
last_tweet: Mutex::new(last_tweet),
posted_tweets: Mutex::new(Vec::new()),
actions: Mutex::new(Vec::new()),
}
}
pub fn posted_count(&self) -> usize {
self.posted_tweets.lock().expect("lock").len()
}
pub fn action_count(&self) -> usize {
self.actions.lock().expect("lock").len()
}
}
#[async_trait::async_trait]
impl ContentStorage for MockStorage {
async fn last_tweet_time(
&self,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
Ok(*self.last_tweet.lock().expect("lock"))
}
async fn last_thread_time(
&self,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
Ok(None)
}
async fn todays_tweet_times(
&self,
) -> Result<Vec<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
Ok(Vec::new())
}
async fn post_tweet(&self, topic: &str, content: &str) -> Result<(), ContentLoopError> {
self.posted_tweets
.lock()
.expect("lock")
.push((topic.to_string(), content.to_string()));
Ok(())
}
async fn create_thread(
&self,
_topic: &str,
_tweet_count: usize,
) -> Result<String, ContentLoopError> {
Ok("thread-1".to_string())
}
async fn update_thread_status(
&self,
_thread_id: &str,
_status: &str,
_tweet_count: usize,
_root_tweet_id: Option<&str>,
) -> Result<(), ContentLoopError> {
Ok(())
}
async fn store_thread_tweet(
&self,
_thread_id: &str,
_position: usize,
_tweet_id: &str,
_content: &str,
) -> Result<(), ContentLoopError> {
Ok(())
}
async fn log_action(
&self,
action_type: &str,
status: &str,
message: &str,
) -> Result<(), ContentLoopError> {
self.actions.lock().expect("lock").push((
action_type.to_string(),
status.to_string(),
message.to_string(),
));
Ok(())
}
}
pub struct MockTopicScorer {
pub top_topics: Vec<String>,
}
#[async_trait::async_trait]
impl TopicScorer for MockTopicScorer {
async fn get_top_topics(&self, _limit: u32) -> Result<Vec<String>, ContentLoopError> {
Ok(self.top_topics.clone())
}
}
pub struct FailingTopicScorer;
#[async_trait::async_trait]
impl TopicScorer for FailingTopicScorer {
async fn get_top_topics(&self, _limit: u32) -> Result<Vec<String>, ContentLoopError> {
Err(ContentLoopError::StorageError("db error".to_string()))
}
}
pub struct FirstCallRng {
pub first_u64: Option<u64>,
pub inner: rand::rngs::ThreadRng,
}
impl FirstCallRng {
pub fn low_roll() -> Self {
Self {
first_u64: Some(0),
inner: rand::rng(),
}
}
pub fn high_roll() -> Self {
Self {
first_u64: Some(u64::MAX),
inner: rand::rng(),
}
}
}
impl rand::RngCore for FirstCallRng {
fn next_u32(&mut self) -> u32 {
self.inner.next_u32()
}
fn next_u64(&mut self) -> u64 {
if let Some(val) = self.first_u64.take() {
val
} else {
self.inner.next_u64()
}
}
fn fill_bytes(&mut self, dest: &mut [u8]) {
self.inner.fill_bytes(dest);
}
}
pub fn make_topics() -> Vec<String> {
vec![
"Rust".to_string(),
"CLI tools".to_string(),
"Open source".to_string(),
"Developer productivity".to_string(),
]
}
}
#[cfg(test)]
mod tests_content_loop {
use super::test_mocks::{make_topics, MockGenerator, MockSafety, MockStorage, MockTopicScorer};
use super::{ContentLoop, ContentResult, EXPLOIT_RATIO};
use std::sync::Arc;
#[test]
fn exploit_ratio_value() {
assert!((EXPLOIT_RATIO - 0.8).abs() < f64::EPSILON);
}
#[test]
fn content_loop_new_fields() {
let content = ContentLoop::new(
Arc::new(MockGenerator {
response: "tweet".to_string(),
}),
Arc::new(MockSafety {
can_tweet: true,
can_thread: true,
}),
Arc::new(MockStorage::new(None)),
make_topics(),
14400,
true,
);
assert!(content.dry_run);
assert_eq!(content.post_window_secs, 14400);
assert_eq!(content.topics.len(), 4);
assert!(content.topic_scorer.is_none());
assert!(content.thread_poster.is_none());
}
#[test]
fn content_loop_with_topic_scorer() {
let scorer = Arc::new(MockTopicScorer {
top_topics: vec!["Rust".to_string()],
});
let content = ContentLoop::new(
Arc::new(MockGenerator {
response: "t".to_string(),
}),
Arc::new(MockSafety {
can_tweet: true,
can_thread: true,
}),
Arc::new(MockStorage::new(None)),
make_topics(),
14400,
false,
)
.with_topic_scorer(scorer);
assert!(content.topic_scorer.is_some());
}
#[test]
fn content_result_debug() {
let posted = ContentResult::Posted {
topic: "Rust".to_string(),
content: "hello".to_string(),
};
let debug = format!("{:?}", posted);
assert!(debug.contains("Posted"));
let too_soon = ContentResult::TooSoon {
elapsed_secs: 10,
window_secs: 3600,
};
let debug = format!("{:?}", too_soon);
assert!(debug.contains("TooSoon"));
let rate_limited = ContentResult::RateLimited;
let debug = format!("{:?}", rate_limited);
assert!(debug.contains("RateLimited"));
let no_topics = ContentResult::NoTopics;
let debug = format!("{:?}", no_topics);
assert!(debug.contains("NoTopics"));
let failed = ContentResult::Failed {
error: "oops".to_string(),
};
let debug = format!("{:?}", failed);
assert!(debug.contains("Failed"));
}
#[test]
fn content_loop_empty_topics() {
let content = ContentLoop::new(
Arc::new(MockGenerator {
response: "t".to_string(),
}),
Arc::new(MockSafety {
can_tweet: true,
can_thread: true,
}),
Arc::new(MockStorage::new(None)),
vec![],
14400,
false,
);
assert!(content.topics.is_empty());
}
#[test]
fn content_loop_with_thread_poster() {
use crate::automation::loop_helpers::ThreadPoster;
use crate::automation::ContentLoopError;
struct MockThreadPoster;
#[async_trait::async_trait]
impl ThreadPoster for MockThreadPoster {
async fn post_tweet(&self, _content: &str) -> Result<String, ContentLoopError> {
Ok("tweet_id_1".to_string())
}
async fn reply_to_tweet(
&self,
_in_reply_to: &str,
_content: &str,
) -> Result<String, ContentLoopError> {
Ok("reply_id_1".to_string())
}
}
let poster = Arc::new(MockThreadPoster);
let content = ContentLoop::new(
Arc::new(MockGenerator {
response: "t".to_string(),
}),
Arc::new(MockSafety {
can_tweet: true,
can_thread: true,
}),
Arc::new(MockStorage::new(None)),
make_topics(),
14400,
false,
)
.with_thread_poster(poster);
assert!(content.thread_poster.is_some());
}
#[test]
fn mock_storage_counts() {
let storage = MockStorage::new(None);
assert_eq!(storage.posted_count(), 0);
assert_eq!(storage.action_count(), 0);
}
#[test]
fn content_loop_dry_run_false() {
let content = ContentLoop::new(
Arc::new(MockGenerator {
response: "t".to_string(),
}),
Arc::new(MockSafety {
can_tweet: true,
can_thread: true,
}),
Arc::new(MockStorage::new(None)),
make_topics(),
3600,
false,
);
assert!(!content.dry_run);
assert_eq!(content.post_window_secs, 3600);
}
}