mod generator;
mod planner;
#[cfg(test)]
mod tests_guardrails;
use super::loop_helpers::{ContentLoopError, ContentSafety, ContentStorage, ThreadPoster};
use std::sync::Arc;
pub struct ThreadLoop {
pub(super) generator: Arc<dyn ThreadGenerator>,
pub(super) safety: Arc<dyn ContentSafety>,
pub(super) storage: Arc<dyn ContentStorage>,
pub(super) poster: Arc<dyn ThreadPoster>,
pub(super) topics: Vec<String>,
pub(super) thread_interval_secs: u64,
pub(super) dry_run: bool,
}
#[async_trait::async_trait]
pub trait ThreadGenerator: Send + Sync {
async fn generate_thread(
&self,
topic: &str,
count: Option<usize>,
) -> Result<Vec<String>, ContentLoopError>;
}
#[derive(Debug)]
pub enum ThreadResult {
Posted {
topic: String,
tweet_count: usize,
thread_id: String,
},
PartialFailure {
topic: String,
tweets_posted: usize,
total_tweets: usize,
error: String,
},
TooSoon {
elapsed_secs: u64,
interval_secs: u64,
},
RateLimited,
NoTopics,
ValidationFailed { error: String },
Failed { error: String },
}
impl ThreadLoop {
#[allow(clippy::too_many_arguments)]
pub fn new(
generator: Arc<dyn ThreadGenerator>,
safety: Arc<dyn ContentSafety>,
storage: Arc<dyn ContentStorage>,
poster: Arc<dyn ThreadPoster>,
topics: Vec<String>,
thread_interval_secs: u64,
dry_run: bool,
) -> Self {
Self {
generator,
safety,
storage,
poster,
topics,
thread_interval_secs,
dry_run,
}
}
}
pub(super) fn pick_topic(
topics: &[String],
recent: &mut Vec<String>,
rng: &mut impl rand::Rng,
) -> String {
use rand::seq::IndexedRandom;
let available: Vec<&String> = topics.iter().filter(|t| !recent.contains(t)).collect();
if available.is_empty() {
recent.clear();
topics.choose(rng).expect("topics is non-empty").clone()
} else {
available
.choose(rng)
.expect("available is non-empty")
.to_string()
}
}
#[cfg(test)]
pub(super) mod test_mocks {
use super::ThreadGenerator;
use crate::automation::loop_helpers::{
ContentLoopError, ContentSafety, ContentStorage, ThreadPoster,
};
use std::sync::Mutex;
pub struct MockThreadGenerator {
pub tweets: Vec<String>,
}
#[async_trait::async_trait]
impl ThreadGenerator for MockThreadGenerator {
async fn generate_thread(
&self,
_topic: &str,
_count: Option<usize>,
) -> Result<Vec<String>, ContentLoopError> {
Ok(self.tweets.clone())
}
}
pub struct OverlongThreadGenerator;
#[async_trait::async_trait]
impl ThreadGenerator for OverlongThreadGenerator {
async fn generate_thread(
&self,
_topic: &str,
_count: Option<usize>,
) -> Result<Vec<String>, ContentLoopError> {
Ok(vec!["a".repeat(300), "b".repeat(300)])
}
}
pub struct FailingThreadGenerator;
#[async_trait::async_trait]
impl ThreadGenerator for FailingThreadGenerator {
async fn generate_thread(
&self,
_topic: &str,
_count: Option<usize>,
) -> Result<Vec<String>, ContentLoopError> {
Err(ContentLoopError::LlmFailure("model error".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_thread: Mutex<Option<chrono::DateTime<chrono::Utc>>>,
pub threads: Mutex<Vec<(String, usize)>>,
pub thread_statuses: Mutex<Vec<(String, String, usize)>>,
pub thread_tweets: Mutex<Vec<(String, usize, String, String)>>,
pub actions: Mutex<Vec<(String, String, String)>>,
}
impl MockStorage {
pub fn new(last_thread: Option<chrono::DateTime<chrono::Utc>>) -> Self {
Self {
last_thread: Mutex::new(last_thread),
threads: Mutex::new(Vec::new()),
thread_statuses: Mutex::new(Vec::new()),
thread_tweets: Mutex::new(Vec::new()),
actions: Mutex::new(Vec::new()),
}
}
pub fn thread_tweet_count(&self) -> usize {
self.thread_tweets.lock().expect("lock").len()
}
pub fn action_statuses(&self) -> Vec<String> {
self.actions
.lock()
.expect("lock")
.iter()
.map(|(_, s, _)| s.clone())
.collect()
}
}
#[async_trait::async_trait]
impl ContentStorage for MockStorage {
async fn last_tweet_time(
&self,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
Ok(None)
}
async fn last_thread_time(
&self,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
Ok(*self.last_thread.lock().expect("lock"))
}
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> {
Ok(())
}
async fn create_thread(
&self,
topic: &str,
tweet_count: usize,
) -> Result<String, ContentLoopError> {
let id = format!("thread-{}", self.threads.lock().expect("lock").len() + 1);
self.threads
.lock()
.expect("lock")
.push((topic.to_string(), tweet_count));
Ok(id)
}
async fn update_thread_status(
&self,
thread_id: &str,
status: &str,
tweet_count: usize,
_root_tweet_id: Option<&str>,
) -> Result<(), ContentLoopError> {
self.thread_statuses.lock().expect("lock").push((
thread_id.to_string(),
status.to_string(),
tweet_count,
));
Ok(())
}
async fn store_thread_tweet(
&self,
thread_id: &str,
position: usize,
tweet_id: &str,
content: &str,
) -> Result<(), ContentLoopError> {
self.thread_tweets.lock().expect("lock").push((
thread_id.to_string(),
position,
tweet_id.to_string(),
content.to_string(),
));
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 MockPoster {
pub posted: Mutex<Vec<(Option<String>, String)>>,
pub fail_at_index: Option<usize>,
}
impl MockPoster {
pub fn new() -> Self {
Self {
posted: Mutex::new(Vec::new()),
fail_at_index: None,
}
}
pub fn failing_at(index: usize) -> Self {
Self {
posted: Mutex::new(Vec::new()),
fail_at_index: Some(index),
}
}
pub fn posted_count(&self) -> usize {
self.posted.lock().expect("lock").len()
}
}
#[async_trait::async_trait]
impl ThreadPoster for MockPoster {
async fn post_tweet(&self, content: &str) -> Result<String, ContentLoopError> {
let mut posted = self.posted.lock().expect("lock");
if self.fail_at_index == Some(posted.len()) {
return Err(ContentLoopError::PostFailed("API error".to_string()));
}
let id = format!("tweet-{}", posted.len() + 1);
posted.push((None, content.to_string()));
Ok(id)
}
async fn reply_to_tweet(
&self,
in_reply_to: &str,
content: &str,
) -> Result<String, ContentLoopError> {
let mut posted = self.posted.lock().expect("lock");
if self.fail_at_index == Some(posted.len()) {
return Err(ContentLoopError::PostFailed("API error".to_string()));
}
let id = format!("tweet-{}", posted.len() + 1);
posted.push((Some(in_reply_to.to_string()), content.to_string()));
Ok(id)
}
}
pub fn make_topics() -> Vec<String> {
vec![
"Rust".to_string(),
"CLI tools".to_string(),
"Open source".to_string(),
]
}
pub fn make_thread_tweets() -> Vec<String> {
vec![
"Thread on Rust: Let me share what I've learned...".to_string(),
"First, the ownership model is game-changing.".to_string(),
"Second, pattern matching makes error handling elegant.".to_string(),
"Third, the compiler is your best friend.".to_string(),
"Finally, the community is incredibly welcoming.".to_string(),
]
}
}
#[cfg(test)]
mod tests_pick_topic {
use super::pick_topic;
#[test]
fn pick_avoids_recent() {
let topics = vec!["A".to_string(), "B".to_string(), "C".to_string()];
let mut recent = vec!["A".to_string(), "B".to_string()];
let mut rng = rand::rng();
for _ in 0..20 {
let topic = pick_topic(&topics, &mut recent, &mut rng);
assert_eq!(topic, "C");
}
}
#[test]
fn pick_clears_when_all_recent() {
let topics = vec!["A".to_string(), "B".to_string()];
let mut recent = vec!["A".to_string(), "B".to_string()];
let mut rng = rand::rng();
let topic = pick_topic(&topics, &mut recent, &mut rng);
assert!(topics.contains(&topic));
assert!(recent.is_empty()); }
#[test]
fn pick_single_topic() {
let topics = vec!["Only".to_string()];
let mut recent = Vec::new();
let mut rng = rand::rng();
let topic = pick_topic(&topics, &mut recent, &mut rng);
assert_eq!(topic, "Only");
}
#[test]
fn pick_rotates_through_all() {
let topics = vec!["X".to_string(), "Y".to_string(), "Z".to_string()];
let mut recent = Vec::new();
let mut rng = rand::rng();
let mut seen = std::collections::HashSet::new();
for _ in 0..100 {
let topic = pick_topic(&topics, &mut recent, &mut rng);
seen.insert(topic);
recent.clear(); }
assert_eq!(seen.len(), 3);
}
}