Skip to main content

tuitbot_core/automation/thread_loop/
mod.rs

1//! Thread loop for posting multi-tweet educational threads.
2//!
3//! Generates and posts educational threads (5-8 tweets) as reply chains
4//! on a configurable schedule. Threads bypass the posting queue since
5//! reply chain order must be maintained (each tweet replies to the previous).
6//!
7//! # Module layout
8//!
9//! | File            | Responsibility                                         |
10//! |-----------------|--------------------------------------------------------|
11//! | `mod.rs`        | Public types, constructor, shared test mocks           |
12//! | `planner.rs`    | Run loop, scheduling, iteration, topic selection       |
13//! | `generator.rs`  | Content generation, validation, reply-chain posting    |
14
15mod generator;
16mod planner;
17#[cfg(test)]
18mod tests_guardrails; // Task 3.5: safety guardrails + thread semantics
19
20use super::loop_helpers::{ContentLoopError, ContentSafety, ContentStorage, ThreadPoster};
21use std::sync::Arc;
22
23/// Thread loop that generates and posts educational threads.
24pub 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/// Trait for generating multi-tweet threads.
35#[async_trait::async_trait]
36pub trait ThreadGenerator: Send + Sync {
37    /// Generate a thread of tweets on the given topic.
38    ///
39    /// If `count` is Some, generate exactly that many tweets.
40    /// Otherwise, the LLM decides (typically 5-8).
41    async fn generate_thread(
42        &self,
43        topic: &str,
44        count: Option<usize>,
45    ) -> Result<Vec<String>, ContentLoopError>;
46}
47
48/// Result of a thread generation/posting attempt.
49#[derive(Debug)]
50pub enum ThreadResult {
51    /// Thread was posted (or would be in dry-run).
52    Posted {
53        topic: String,
54        tweet_count: usize,
55        thread_id: String,
56    },
57    /// Thread partially posted (some tweets succeeded, one failed).
58    PartialFailure {
59        topic: String,
60        tweets_posted: usize,
61        total_tweets: usize,
62        error: String,
63    },
64    /// Skipped because not enough time has elapsed since last thread.
65    TooSoon {
66        elapsed_secs: u64,
67        interval_secs: u64,
68    },
69    /// Skipped due to weekly thread rate limit.
70    RateLimited,
71    /// No topics configured.
72    NoTopics,
73    /// Content validation failed after max retries.
74    ValidationFailed { error: String },
75    /// Generation failed.
76    Failed { error: String },
77}
78
79impl ThreadLoop {
80    /// Create a new thread loop.
81    #[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
103/// Pick a topic that is not in the recent list.
104/// If all topics are recent, clear the list and pick any.
105pub(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// ---------------------------------------------------------------------------
125// Shared test mocks (accessible to all child test modules via super::test_mocks)
126// ---------------------------------------------------------------------------
127
128#[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    // --- thread generators ---
137
138    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    // --- safety ---
180
181    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    // --- storage ---
197
198    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    // --- poster ---
315
316    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    // --- fixtures ---
369
370    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()); // cleared
414    }
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(); // reset for next iteration
437        }
438        assert_eq!(seen.len(), 3);
439    }
440}