Skip to main content

tuitbot_core/automation/content_loop/
mod.rs

1//! Content loop for posting original educational tweets.
2//!
3//! Generates and posts original educational tweets on a configurable
4//! schedule, keeping the user's X account active with thought-leadership
5//! content. Rotates through configured topics to avoid repetition.
6//!
7//! # Module layout
8//!
9//! | File            | Responsibility                                        |
10//! |-----------------|-------------------------------------------------------|
11//! | `mod.rs`        | Struct definition, constructors, shared test mocks   |
12//! | `generator.rs`  | Tweet generation, topic selection, text utilities     |
13//! | `scheduler.rs`  | Run loop, iteration logic, slot/interval scheduling   |
14//! | `publisher.rs`  | Scheduled-content posting (single tweets & threads)   |
15
16mod generator;
17mod publisher;
18mod scheduler;
19#[cfg(test)]
20mod tests_guardrails; // Task 3.5: safety guardrails + publisher tests
21
22use super::loop_helpers::{
23    ContentSafety, ContentStorage, ThreadPoster, TopicScorer, TweetGenerator,
24};
25use std::sync::Arc;
26
27/// Fraction of the time to exploit top-performing topics (vs. explore random ones).
28pub(super) const EXPLOIT_RATIO: f64 = 0.8;
29
30/// Content loop that generates and posts original educational tweets.
31pub struct ContentLoop {
32    pub(super) generator: Arc<dyn TweetGenerator>,
33    pub(super) safety: Arc<dyn ContentSafety>,
34    pub(super) storage: Arc<dyn ContentStorage>,
35    pub(super) topic_scorer: Option<Arc<dyn TopicScorer>>,
36    pub(super) thread_poster: Option<Arc<dyn ThreadPoster>>,
37    pub(super) topics: Vec<String>,
38    pub(super) post_window_secs: u64,
39    pub(super) dry_run: bool,
40}
41
42/// Result of a content generation attempt.
43#[derive(Debug)]
44pub enum ContentResult {
45    /// Tweet was posted (or would be in dry-run).
46    Posted { topic: String, content: String },
47    /// Skipped because not enough time has elapsed since last tweet.
48    TooSoon { elapsed_secs: u64, window_secs: u64 },
49    /// Skipped due to daily tweet rate limit.
50    RateLimited,
51    /// No topics configured.
52    NoTopics,
53    /// Generation failed.
54    Failed { error: String },
55}
56
57impl ContentLoop {
58    /// Create a new content loop.
59    pub fn new(
60        generator: Arc<dyn TweetGenerator>,
61        safety: Arc<dyn ContentSafety>,
62        storage: Arc<dyn ContentStorage>,
63        topics: Vec<String>,
64        post_window_secs: u64,
65        dry_run: bool,
66    ) -> Self {
67        Self {
68            generator,
69            safety,
70            storage,
71            topic_scorer: None,
72            thread_poster: None,
73            topics,
74            post_window_secs,
75            dry_run,
76        }
77    }
78
79    /// Set a topic scorer for epsilon-greedy topic selection.
80    ///
81    /// When set, 80% of the time the loop picks from top-performing topics
82    /// (exploit), and 20% of the time it picks a random topic (explore).
83    pub fn with_topic_scorer(mut self, scorer: Arc<dyn TopicScorer>) -> Self {
84        self.topic_scorer = Some(scorer);
85        self
86    }
87
88    /// Set a thread poster for posting scheduled threads as reply chains.
89    pub fn with_thread_poster(mut self, poster: Arc<dyn ThreadPoster>) -> Self {
90        self.thread_poster = Some(poster);
91        self
92    }
93}
94
95// ---------------------------------------------------------------------------
96// Shared test mocks (accessible to all child test modules).
97// ---------------------------------------------------------------------------
98
99#[cfg(test)]
100pub(super) mod test_mocks {
101    use crate::automation::loop_helpers::{
102        ContentSafety, ContentStorage, TopicScorer, TweetGenerator,
103    };
104    use crate::automation::ContentLoopError;
105    use std::sync::Mutex;
106
107    // --- generators ---
108
109    pub struct MockGenerator {
110        pub response: String,
111    }
112
113    #[async_trait::async_trait]
114    impl TweetGenerator for MockGenerator {
115        async fn generate_tweet(&self, _topic: &str) -> Result<String, ContentLoopError> {
116            Ok(self.response.clone())
117        }
118    }
119
120    pub struct OverlongGenerator {
121        pub first_response: String,
122        pub retry_response: String,
123        pub call_count: Mutex<usize>,
124    }
125
126    #[async_trait::async_trait]
127    impl TweetGenerator for OverlongGenerator {
128        async fn generate_tweet(&self, _topic: &str) -> Result<String, ContentLoopError> {
129            let mut count = self.call_count.lock().expect("lock");
130            *count += 1;
131            if *count == 1 {
132                Ok(self.first_response.clone())
133            } else {
134                Ok(self.retry_response.clone())
135            }
136        }
137    }
138
139    pub struct FailingGenerator;
140
141    #[async_trait::async_trait]
142    impl TweetGenerator for FailingGenerator {
143        async fn generate_tweet(&self, _topic: &str) -> Result<String, ContentLoopError> {
144            Err(ContentLoopError::LlmFailure(
145                "model unavailable".to_string(),
146            ))
147        }
148    }
149
150    // --- safety ---
151
152    pub struct MockSafety {
153        pub can_tweet: bool,
154        pub can_thread: bool,
155    }
156
157    #[async_trait::async_trait]
158    impl ContentSafety for MockSafety {
159        async fn can_post_tweet(&self) -> bool {
160            self.can_tweet
161        }
162        async fn can_post_thread(&self) -> bool {
163            self.can_thread
164        }
165    }
166
167    // --- storage ---
168
169    pub struct MockStorage {
170        pub last_tweet: Mutex<Option<chrono::DateTime<chrono::Utc>>>,
171        pub posted_tweets: Mutex<Vec<(String, String)>>,
172        pub actions: Mutex<Vec<(String, String, String)>>,
173    }
174
175    impl MockStorage {
176        pub fn new(last_tweet: Option<chrono::DateTime<chrono::Utc>>) -> Self {
177            Self {
178                last_tweet: Mutex::new(last_tweet),
179                posted_tweets: Mutex::new(Vec::new()),
180                actions: Mutex::new(Vec::new()),
181            }
182        }
183
184        pub fn posted_count(&self) -> usize {
185            self.posted_tweets.lock().expect("lock").len()
186        }
187
188        pub fn action_count(&self) -> usize {
189            self.actions.lock().expect("lock").len()
190        }
191    }
192
193    #[async_trait::async_trait]
194    impl ContentStorage for MockStorage {
195        async fn last_tweet_time(
196            &self,
197        ) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
198            Ok(*self.last_tweet.lock().expect("lock"))
199        }
200
201        async fn last_thread_time(
202            &self,
203        ) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
204            Ok(None)
205        }
206
207        async fn todays_tweet_times(
208            &self,
209        ) -> Result<Vec<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
210            Ok(Vec::new())
211        }
212
213        async fn post_tweet(&self, topic: &str, content: &str) -> Result<(), ContentLoopError> {
214            self.posted_tweets
215                .lock()
216                .expect("lock")
217                .push((topic.to_string(), content.to_string()));
218            Ok(())
219        }
220
221        async fn create_thread(
222            &self,
223            _topic: &str,
224            _tweet_count: usize,
225        ) -> Result<String, ContentLoopError> {
226            Ok("thread-1".to_string())
227        }
228
229        async fn update_thread_status(
230            &self,
231            _thread_id: &str,
232            _status: &str,
233            _tweet_count: usize,
234            _root_tweet_id: Option<&str>,
235        ) -> Result<(), ContentLoopError> {
236            Ok(())
237        }
238
239        async fn store_thread_tweet(
240            &self,
241            _thread_id: &str,
242            _position: usize,
243            _tweet_id: &str,
244            _content: &str,
245        ) -> Result<(), ContentLoopError> {
246            Ok(())
247        }
248
249        async fn log_action(
250            &self,
251            action_type: &str,
252            status: &str,
253            message: &str,
254        ) -> Result<(), ContentLoopError> {
255            self.actions.lock().expect("lock").push((
256                action_type.to_string(),
257                status.to_string(),
258                message.to_string(),
259            ));
260            Ok(())
261        }
262    }
263
264    // --- topic scorer ---
265
266    pub struct MockTopicScorer {
267        pub top_topics: Vec<String>,
268    }
269
270    #[async_trait::async_trait]
271    impl TopicScorer for MockTopicScorer {
272        async fn get_top_topics(&self, _limit: u32) -> Result<Vec<String>, ContentLoopError> {
273            Ok(self.top_topics.clone())
274        }
275    }
276
277    pub struct FailingTopicScorer;
278
279    #[async_trait::async_trait]
280    impl TopicScorer for FailingTopicScorer {
281        async fn get_top_topics(&self, _limit: u32) -> Result<Vec<String>, ContentLoopError> {
282            Err(ContentLoopError::StorageError("db error".to_string()))
283        }
284    }
285
286    // --- RNG helper ---
287
288    /// RNG wrapper that overrides only the first `next_u64()` call,
289    /// then delegates everything to a real ThreadRng. This lets us
290    /// control the initial `gen::<f64>()` roll without breaking
291    /// subsequent `choose()` / `gen_range()` calls.
292    pub struct FirstCallRng {
293        pub first_u64: Option<u64>,
294        pub inner: rand::rngs::ThreadRng,
295    }
296
297    impl FirstCallRng {
298        /// Create an RNG whose first `gen::<f64>()` returns ~0.0 (exploit).
299        pub fn low_roll() -> Self {
300            Self {
301                first_u64: Some(0),
302                inner: rand::rng(),
303            }
304        }
305
306        /// Create an RNG whose first `gen::<f64>()` returns ~1.0 (explore).
307        pub fn high_roll() -> Self {
308            Self {
309                first_u64: Some(u64::MAX),
310                inner: rand::rng(),
311            }
312        }
313    }
314
315    impl rand::RngCore for FirstCallRng {
316        fn next_u32(&mut self) -> u32 {
317            self.inner.next_u32()
318        }
319        fn next_u64(&mut self) -> u64 {
320            if let Some(val) = self.first_u64.take() {
321                val
322            } else {
323                self.inner.next_u64()
324            }
325        }
326        fn fill_bytes(&mut self, dest: &mut [u8]) {
327            self.inner.fill_bytes(dest);
328        }
329    }
330
331    // --- fixtures ---
332
333    pub fn make_topics() -> Vec<String> {
334        vec![
335            "Rust".to_string(),
336            "CLI tools".to_string(),
337            "Open source".to_string(),
338            "Developer productivity".to_string(),
339        ]
340    }
341}
342
343#[cfg(test)]
344mod tests_content_loop {
345    use super::test_mocks::{make_topics, MockGenerator, MockSafety, MockStorage, MockTopicScorer};
346    use super::{ContentLoop, ContentResult, EXPLOIT_RATIO};
347    use std::sync::Arc;
348
349    #[test]
350    fn exploit_ratio_value() {
351        assert!((EXPLOIT_RATIO - 0.8).abs() < f64::EPSILON);
352    }
353
354    #[test]
355    fn content_loop_new_fields() {
356        let content = ContentLoop::new(
357            Arc::new(MockGenerator {
358                response: "tweet".to_string(),
359            }),
360            Arc::new(MockSafety {
361                can_tweet: true,
362                can_thread: true,
363            }),
364            Arc::new(MockStorage::new(None)),
365            make_topics(),
366            14400,
367            true,
368        );
369
370        assert!(content.dry_run);
371        assert_eq!(content.post_window_secs, 14400);
372        assert_eq!(content.topics.len(), 4);
373        assert!(content.topic_scorer.is_none());
374        assert!(content.thread_poster.is_none());
375    }
376
377    #[test]
378    fn content_loop_with_topic_scorer() {
379        let scorer = Arc::new(MockTopicScorer {
380            top_topics: vec!["Rust".to_string()],
381        });
382
383        let content = ContentLoop::new(
384            Arc::new(MockGenerator {
385                response: "t".to_string(),
386            }),
387            Arc::new(MockSafety {
388                can_tweet: true,
389                can_thread: true,
390            }),
391            Arc::new(MockStorage::new(None)),
392            make_topics(),
393            14400,
394            false,
395        )
396        .with_topic_scorer(scorer);
397
398        assert!(content.topic_scorer.is_some());
399    }
400
401    #[test]
402    fn content_result_debug() {
403        let posted = ContentResult::Posted {
404            topic: "Rust".to_string(),
405            content: "hello".to_string(),
406        };
407        let debug = format!("{:?}", posted);
408        assert!(debug.contains("Posted"));
409
410        let too_soon = ContentResult::TooSoon {
411            elapsed_secs: 10,
412            window_secs: 3600,
413        };
414        let debug = format!("{:?}", too_soon);
415        assert!(debug.contains("TooSoon"));
416
417        let rate_limited = ContentResult::RateLimited;
418        let debug = format!("{:?}", rate_limited);
419        assert!(debug.contains("RateLimited"));
420
421        let no_topics = ContentResult::NoTopics;
422        let debug = format!("{:?}", no_topics);
423        assert!(debug.contains("NoTopics"));
424
425        let failed = ContentResult::Failed {
426            error: "oops".to_string(),
427        };
428        let debug = format!("{:?}", failed);
429        assert!(debug.contains("Failed"));
430    }
431
432    #[test]
433    fn content_loop_empty_topics() {
434        let content = ContentLoop::new(
435            Arc::new(MockGenerator {
436                response: "t".to_string(),
437            }),
438            Arc::new(MockSafety {
439                can_tweet: true,
440                can_thread: true,
441            }),
442            Arc::new(MockStorage::new(None)),
443            vec![],
444            14400,
445            false,
446        );
447        assert!(content.topics.is_empty());
448    }
449
450    #[test]
451    fn content_loop_with_thread_poster() {
452        use crate::automation::loop_helpers::ThreadPoster;
453        use crate::automation::ContentLoopError;
454
455        struct MockThreadPoster;
456
457        #[async_trait::async_trait]
458        impl ThreadPoster for MockThreadPoster {
459            async fn post_tweet(&self, _content: &str) -> Result<String, ContentLoopError> {
460                Ok("tweet_id_1".to_string())
461            }
462            async fn reply_to_tweet(
463                &self,
464                _in_reply_to: &str,
465                _content: &str,
466            ) -> Result<String, ContentLoopError> {
467                Ok("reply_id_1".to_string())
468            }
469        }
470
471        let poster = Arc::new(MockThreadPoster);
472        let content = ContentLoop::new(
473            Arc::new(MockGenerator {
474                response: "t".to_string(),
475            }),
476            Arc::new(MockSafety {
477                can_tweet: true,
478                can_thread: true,
479            }),
480            Arc::new(MockStorage::new(None)),
481            make_topics(),
482            14400,
483            false,
484        )
485        .with_thread_poster(poster);
486
487        assert!(content.thread_poster.is_some());
488    }
489
490    #[test]
491    fn mock_storage_counts() {
492        let storage = MockStorage::new(None);
493        assert_eq!(storage.posted_count(), 0);
494        assert_eq!(storage.action_count(), 0);
495    }
496
497    #[test]
498    fn content_loop_dry_run_false() {
499        let content = ContentLoop::new(
500            Arc::new(MockGenerator {
501                response: "t".to_string(),
502            }),
503            Arc::new(MockSafety {
504                can_tweet: true,
505                can_thread: true,
506            }),
507            Arc::new(MockStorage::new(None)),
508            make_topics(),
509            3600,
510            false,
511        );
512        assert!(!content.dry_run);
513        assert_eq!(content.post_window_secs, 3600);
514    }
515}