1mod generator;
17mod publisher;
18mod scheduler;
19#[cfg(test)]
20mod tests_guardrails; use super::loop_helpers::{
23 ContentSafety, ContentStorage, ThreadPoster, TopicScorer, TweetGenerator,
24};
25use std::sync::Arc;
26
27pub(super) const EXPLOIT_RATIO: f64 = 0.8;
29
30pub 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#[derive(Debug)]
44pub enum ContentResult {
45 Posted { topic: String, content: String },
47 TooSoon { elapsed_secs: u64, window_secs: u64 },
49 RateLimited,
51 NoTopics,
53 Failed { error: String },
55}
56
57impl ContentLoop {
58 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 pub fn with_topic_scorer(mut self, scorer: Arc<dyn TopicScorer>) -> Self {
84 self.topic_scorer = Some(scorer);
85 self
86 }
87
88 pub fn with_thread_poster(mut self, poster: Arc<dyn ThreadPoster>) -> Self {
90 self.thread_poster = Some(poster);
91 self
92 }
93}
94
95#[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 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 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 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 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 pub struct FirstCallRng {
293 pub first_u64: Option<u64>,
294 pub inner: rand::rngs::ThreadRng,
295 }
296
297 impl FirstCallRng {
298 pub fn low_roll() -> Self {
300 Self {
301 first_u64: Some(0),
302 inner: rand::rng(),
303 }
304 }
305
306 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 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}