1mod generator;
16mod planner;
17#[cfg(test)]
18mod tests_guardrails; use super::loop_helpers::{ContentLoopError, ContentSafety, ContentStorage, ThreadPoster};
21use std::sync::Arc;
22
23pub struct ThreadLoop {
25 pub(super) generator: Arc<dyn ThreadGenerator>,
26 pub(super) safety: Arc<dyn ContentSafety>,
27 pub(super) storage: Arc<dyn ContentStorage>,
28 pub(super) poster: Arc<dyn ThreadPoster>,
29 pub(super) topics: Vec<String>,
30 pub(super) thread_interval_secs: u64,
31 pub(super) dry_run: bool,
32}
33
34#[async_trait::async_trait]
36pub trait ThreadGenerator: Send + Sync {
37 async fn generate_thread(
42 &self,
43 topic: &str,
44 count: Option<usize>,
45 ) -> Result<Vec<String>, ContentLoopError>;
46}
47
48#[derive(Debug)]
50pub enum ThreadResult {
51 Posted {
53 topic: String,
54 tweet_count: usize,
55 thread_id: String,
56 },
57 PartialFailure {
59 topic: String,
60 tweets_posted: usize,
61 total_tweets: usize,
62 error: String,
63 },
64 TooSoon {
66 elapsed_secs: u64,
67 interval_secs: u64,
68 },
69 RateLimited,
71 NoTopics,
73 ValidationFailed { error: String },
75 Failed { error: String },
77}
78
79impl ThreadLoop {
80 #[allow(clippy::too_many_arguments)]
82 pub fn new(
83 generator: Arc<dyn ThreadGenerator>,
84 safety: Arc<dyn ContentSafety>,
85 storage: Arc<dyn ContentStorage>,
86 poster: Arc<dyn ThreadPoster>,
87 topics: Vec<String>,
88 thread_interval_secs: u64,
89 dry_run: bool,
90 ) -> Self {
91 Self {
92 generator,
93 safety,
94 storage,
95 poster,
96 topics,
97 thread_interval_secs,
98 dry_run,
99 }
100 }
101}
102
103pub(super) fn pick_topic(
106 topics: &[String],
107 recent: &mut Vec<String>,
108 rng: &mut impl rand::Rng,
109) -> String {
110 use rand::seq::IndexedRandom;
111 let available: Vec<&String> = topics.iter().filter(|t| !recent.contains(t)).collect();
112
113 if available.is_empty() {
114 recent.clear();
115 topics.choose(rng).expect("topics is non-empty").clone()
116 } else {
117 available
118 .choose(rng)
119 .expect("available is non-empty")
120 .to_string()
121 }
122}
123
124#[cfg(test)]
129pub(super) mod test_mocks {
130 use super::ThreadGenerator;
131 use crate::automation::loop_helpers::{
132 ContentLoopError, ContentSafety, ContentStorage, ThreadPoster,
133 };
134 use std::sync::Mutex;
135
136 pub struct MockThreadGenerator {
139 pub tweets: Vec<String>,
140 }
141
142 #[async_trait::async_trait]
143 impl ThreadGenerator for MockThreadGenerator {
144 async fn generate_thread(
145 &self,
146 _topic: &str,
147 _count: Option<usize>,
148 ) -> Result<Vec<String>, ContentLoopError> {
149 Ok(self.tweets.clone())
150 }
151 }
152
153 pub struct OverlongThreadGenerator;
154
155 #[async_trait::async_trait]
156 impl ThreadGenerator for OverlongThreadGenerator {
157 async fn generate_thread(
158 &self,
159 _topic: &str,
160 _count: Option<usize>,
161 ) -> Result<Vec<String>, ContentLoopError> {
162 Ok(vec!["a".repeat(300), "b".repeat(300)])
163 }
164 }
165
166 pub struct FailingThreadGenerator;
167
168 #[async_trait::async_trait]
169 impl ThreadGenerator for FailingThreadGenerator {
170 async fn generate_thread(
171 &self,
172 _topic: &str,
173 _count: Option<usize>,
174 ) -> Result<Vec<String>, ContentLoopError> {
175 Err(ContentLoopError::LlmFailure("model error".to_string()))
176 }
177 }
178
179 pub struct MockSafety {
182 pub can_tweet: bool,
183 pub can_thread: bool,
184 }
185
186 #[async_trait::async_trait]
187 impl ContentSafety for MockSafety {
188 async fn can_post_tweet(&self) -> bool {
189 self.can_tweet
190 }
191 async fn can_post_thread(&self) -> bool {
192 self.can_thread
193 }
194 }
195
196 pub struct MockStorage {
199 pub last_thread: Mutex<Option<chrono::DateTime<chrono::Utc>>>,
200 pub threads: Mutex<Vec<(String, usize)>>,
201 pub thread_statuses: Mutex<Vec<(String, String, usize)>>,
202 pub thread_tweets: Mutex<Vec<(String, usize, String, String)>>,
203 pub actions: Mutex<Vec<(String, String, String)>>,
204 }
205
206 impl MockStorage {
207 pub fn new(last_thread: Option<chrono::DateTime<chrono::Utc>>) -> Self {
208 Self {
209 last_thread: Mutex::new(last_thread),
210 threads: Mutex::new(Vec::new()),
211 thread_statuses: Mutex::new(Vec::new()),
212 thread_tweets: Mutex::new(Vec::new()),
213 actions: Mutex::new(Vec::new()),
214 }
215 }
216
217 pub fn thread_tweet_count(&self) -> usize {
218 self.thread_tweets.lock().expect("lock").len()
219 }
220
221 pub fn action_statuses(&self) -> Vec<String> {
222 self.actions
223 .lock()
224 .expect("lock")
225 .iter()
226 .map(|(_, s, _)| s.clone())
227 .collect()
228 }
229 }
230
231 #[async_trait::async_trait]
232 impl ContentStorage for MockStorage {
233 async fn last_tweet_time(
234 &self,
235 ) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
236 Ok(None)
237 }
238
239 async fn last_thread_time(
240 &self,
241 ) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
242 Ok(*self.last_thread.lock().expect("lock"))
243 }
244
245 async fn todays_tweet_times(
246 &self,
247 ) -> Result<Vec<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
248 Ok(Vec::new())
249 }
250
251 async fn post_tweet(&self, _topic: &str, _content: &str) -> Result<(), ContentLoopError> {
252 Ok(())
253 }
254
255 async fn create_thread(
256 &self,
257 topic: &str,
258 tweet_count: usize,
259 ) -> Result<String, ContentLoopError> {
260 let id = format!("thread-{}", self.threads.lock().expect("lock").len() + 1);
261 self.threads
262 .lock()
263 .expect("lock")
264 .push((topic.to_string(), tweet_count));
265 Ok(id)
266 }
267
268 async fn update_thread_status(
269 &self,
270 thread_id: &str,
271 status: &str,
272 tweet_count: usize,
273 _root_tweet_id: Option<&str>,
274 ) -> Result<(), ContentLoopError> {
275 self.thread_statuses.lock().expect("lock").push((
276 thread_id.to_string(),
277 status.to_string(),
278 tweet_count,
279 ));
280 Ok(())
281 }
282
283 async fn store_thread_tweet(
284 &self,
285 thread_id: &str,
286 position: usize,
287 tweet_id: &str,
288 content: &str,
289 ) -> Result<(), ContentLoopError> {
290 self.thread_tweets.lock().expect("lock").push((
291 thread_id.to_string(),
292 position,
293 tweet_id.to_string(),
294 content.to_string(),
295 ));
296 Ok(())
297 }
298
299 async fn log_action(
300 &self,
301 action_type: &str,
302 status: &str,
303 message: &str,
304 ) -> Result<(), ContentLoopError> {
305 self.actions.lock().expect("lock").push((
306 action_type.to_string(),
307 status.to_string(),
308 message.to_string(),
309 ));
310 Ok(())
311 }
312 }
313
314 pub struct MockPoster {
317 pub posted: Mutex<Vec<(Option<String>, String)>>,
318 pub fail_at_index: Option<usize>,
319 }
320
321 impl MockPoster {
322 pub fn new() -> Self {
323 Self {
324 posted: Mutex::new(Vec::new()),
325 fail_at_index: None,
326 }
327 }
328
329 pub fn failing_at(index: usize) -> Self {
330 Self {
331 posted: Mutex::new(Vec::new()),
332 fail_at_index: Some(index),
333 }
334 }
335
336 pub fn posted_count(&self) -> usize {
337 self.posted.lock().expect("lock").len()
338 }
339 }
340
341 #[async_trait::async_trait]
342 impl ThreadPoster for MockPoster {
343 async fn post_tweet(&self, content: &str) -> Result<String, ContentLoopError> {
344 let mut posted = self.posted.lock().expect("lock");
345 if self.fail_at_index == Some(posted.len()) {
346 return Err(ContentLoopError::PostFailed("API error".to_string()));
347 }
348 let id = format!("tweet-{}", posted.len() + 1);
349 posted.push((None, content.to_string()));
350 Ok(id)
351 }
352
353 async fn reply_to_tweet(
354 &self,
355 in_reply_to: &str,
356 content: &str,
357 ) -> Result<String, ContentLoopError> {
358 let mut posted = self.posted.lock().expect("lock");
359 if self.fail_at_index == Some(posted.len()) {
360 return Err(ContentLoopError::PostFailed("API error".to_string()));
361 }
362 let id = format!("tweet-{}", posted.len() + 1);
363 posted.push((Some(in_reply_to.to_string()), content.to_string()));
364 Ok(id)
365 }
366 }
367
368 pub fn make_topics() -> Vec<String> {
371 vec![
372 "Rust".to_string(),
373 "CLI tools".to_string(),
374 "Open source".to_string(),
375 ]
376 }
377
378 pub fn make_thread_tweets() -> Vec<String> {
379 vec![
380 "Thread on Rust: Let me share what I've learned...".to_string(),
381 "First, the ownership model is game-changing.".to_string(),
382 "Second, pattern matching makes error handling elegant.".to_string(),
383 "Third, the compiler is your best friend.".to_string(),
384 "Finally, the community is incredibly welcoming.".to_string(),
385 ]
386 }
387}
388
389#[cfg(test)]
390mod tests_pick_topic {
391 use super::pick_topic;
392
393 #[test]
394 fn pick_avoids_recent() {
395 let topics = vec!["A".to_string(), "B".to_string(), "C".to_string()];
396 let mut recent = vec!["A".to_string(), "B".to_string()];
397 let mut rng = rand::rng();
398
399 for _ in 0..20 {
400 let topic = pick_topic(&topics, &mut recent, &mut rng);
401 assert_eq!(topic, "C");
402 }
403 }
404
405 #[test]
406 fn pick_clears_when_all_recent() {
407 let topics = vec!["A".to_string(), "B".to_string()];
408 let mut recent = vec!["A".to_string(), "B".to_string()];
409 let mut rng = rand::rng();
410
411 let topic = pick_topic(&topics, &mut recent, &mut rng);
412 assert!(topics.contains(&topic));
413 assert!(recent.is_empty()); }
415
416 #[test]
417 fn pick_single_topic() {
418 let topics = vec!["Only".to_string()];
419 let mut recent = Vec::new();
420 let mut rng = rand::rng();
421
422 let topic = pick_topic(&topics, &mut recent, &mut rng);
423 assert_eq!(topic, "Only");
424 }
425
426 #[test]
427 fn pick_rotates_through_all() {
428 let topics = vec!["X".to_string(), "Y".to_string(), "Z".to_string()];
429 let mut recent = Vec::new();
430 let mut rng = rand::rng();
431
432 let mut seen = std::collections::HashSet::new();
433 for _ in 0..100 {
434 let topic = pick_topic(&topics, &mut recent, &mut rng);
435 seen.insert(topic);
436 recent.clear(); }
438 assert_eq!(seen.len(), 3);
439 }
440}