1use super::loop_helpers::{
8 ContentSafety, ContentStorage, ThreadPoster, TopicScorer, TweetGenerator,
9};
10use super::schedule::{apply_slot_jitter, schedule_gate, ActiveSchedule};
11use super::scheduler::LoopScheduler;
12use rand::seq::SliceRandom;
13use rand::SeedableRng;
14use std::sync::Arc;
15use tokio_util::sync::CancellationToken;
16
17const EXPLOIT_RATIO: f64 = 0.8;
19
20pub struct ContentLoop {
22 generator: Arc<dyn TweetGenerator>,
23 safety: Arc<dyn ContentSafety>,
24 storage: Arc<dyn ContentStorage>,
25 topic_scorer: Option<Arc<dyn TopicScorer>>,
26 thread_poster: Option<Arc<dyn ThreadPoster>>,
27 topics: Vec<String>,
28 post_window_secs: u64,
29 dry_run: bool,
30}
31
32#[derive(Debug)]
34pub enum ContentResult {
35 Posted { topic: String, content: String },
37 TooSoon { elapsed_secs: u64, window_secs: u64 },
39 RateLimited,
41 NoTopics,
43 Failed { error: String },
45}
46
47impl ContentLoop {
48 pub fn new(
50 generator: Arc<dyn TweetGenerator>,
51 safety: Arc<dyn ContentSafety>,
52 storage: Arc<dyn ContentStorage>,
53 topics: Vec<String>,
54 post_window_secs: u64,
55 dry_run: bool,
56 ) -> Self {
57 Self {
58 generator,
59 safety,
60 storage,
61 topic_scorer: None,
62 thread_poster: None,
63 topics,
64 post_window_secs,
65 dry_run,
66 }
67 }
68
69 pub fn with_topic_scorer(mut self, scorer: Arc<dyn TopicScorer>) -> Self {
74 self.topic_scorer = Some(scorer);
75 self
76 }
77
78 pub fn with_thread_poster(mut self, poster: Arc<dyn ThreadPoster>) -> Self {
80 self.thread_poster = Some(poster);
81 self
82 }
83
84 pub async fn run(
86 &self,
87 cancel: CancellationToken,
88 scheduler: LoopScheduler,
89 schedule: Option<Arc<ActiveSchedule>>,
90 ) {
91 let slot_mode = schedule.as_ref().is_some_and(|s| s.has_preferred_times());
92
93 tracing::info!(
94 dry_run = self.dry_run,
95 topics = self.topics.len(),
96 window_secs = self.post_window_secs,
97 slot_mode = slot_mode,
98 "Content loop started"
99 );
100
101 if self.topics.is_empty() {
102 tracing::warn!("No topics configured, content loop has nothing to post");
103 cancel.cancelled().await;
104 return;
105 }
106
107 let min_recent = 3usize;
108 let max_recent = (self.topics.len() / 2)
109 .max(min_recent)
110 .min(self.topics.len());
111 let mut recent_topics: Vec<String> = Vec::with_capacity(max_recent);
112 let mut rng = rand::rngs::StdRng::from_entropy();
113
114 loop {
115 if cancel.is_cancelled() {
116 break;
117 }
118
119 if !schedule_gate(&schedule, &cancel).await {
120 break;
121 }
122
123 if slot_mode {
124 let sched = schedule.as_ref().expect("slot_mode requires schedule");
126
127 let today_posts = match self.storage.todays_tweet_times().await {
129 Ok(times) => times,
130 Err(e) => {
131 tracing::warn!(error = %e, "Failed to query today's tweet times");
132 Vec::new()
133 }
134 };
135
136 match sched.next_unused_slot(&today_posts) {
137 Some((wait, slot)) => {
138 let jittered_wait = apply_slot_jitter(wait);
139 tracing::info!(
140 slot = %slot.format(),
141 wait_secs = jittered_wait.as_secs(),
142 "Slot mode: sleeping until next posting slot"
143 );
144
145 tokio::select! {
146 _ = cancel.cancelled() => break,
147 _ = tokio::time::sleep(jittered_wait) => {},
148 }
149
150 if cancel.is_cancelled() {
151 break;
152 }
153
154 let result = self
156 .run_slot_iteration(&mut recent_topics, max_recent, &mut rng)
157 .await;
158 self.log_content_result(&result);
159 }
160 None => {
161 tracing::info!(
163 "Slot mode: all slots used today, sleeping until next active period"
164 );
165 if let Some(sched) = &schedule {
166 let wait = sched.time_until_active();
167 if wait.is_zero() {
168 tokio::select! {
170 _ = cancel.cancelled() => break,
171 _ = tokio::time::sleep(std::time::Duration::from_secs(3600)) => {},
172 }
173 } else {
174 tokio::select! {
175 _ = cancel.cancelled() => break,
176 _ = tokio::time::sleep(wait) => {},
177 }
178 }
179 } else {
180 tokio::select! {
181 _ = cancel.cancelled() => break,
182 _ = tokio::time::sleep(std::time::Duration::from_secs(3600)) => {},
183 }
184 }
185 }
186 }
187 } else {
188 let result = self
190 .run_iteration(&mut recent_topics, max_recent, &mut rng)
191 .await;
192 self.log_content_result(&result);
193
194 tokio::select! {
195 _ = cancel.cancelled() => break,
196 _ = scheduler.tick() => {},
197 }
198 }
199 }
200
201 tracing::info!("Content loop stopped");
202 }
203
204 fn log_content_result(&self, result: &ContentResult) {
206 match result {
207 ContentResult::Posted { topic, content } => {
208 tracing::info!(
209 topic = %topic,
210 chars = content.len(),
211 dry_run = self.dry_run,
212 "Content iteration: tweet posted"
213 );
214 }
215 ContentResult::TooSoon {
216 elapsed_secs,
217 window_secs,
218 } => {
219 tracing::debug!(
220 elapsed = elapsed_secs,
221 window = window_secs,
222 "Content iteration: too soon since last tweet"
223 );
224 }
225 ContentResult::RateLimited => {
226 tracing::info!("Content iteration: daily tweet limit reached");
227 }
228 ContentResult::NoTopics => {
229 tracing::warn!("Content iteration: no topics available");
230 }
231 ContentResult::Failed { error } => {
232 tracing::warn!(error = %error, "Content iteration: failed");
233 }
234 }
235 }
236
237 async fn run_slot_iteration(
239 &self,
240 recent_topics: &mut Vec<String>,
241 max_recent: usize,
242 rng: &mut impl rand::Rng,
243 ) -> ContentResult {
244 if let Some(result) = self.try_post_scheduled().await {
246 return result;
247 }
248
249 if !self.safety.can_post_tweet().await {
251 return ContentResult::RateLimited;
252 }
253
254 let topic = self.pick_topic_epsilon_greedy(recent_topics, rng).await;
256
257 let result = self.generate_and_post(&topic).await;
258
259 if matches!(result, ContentResult::Posted { .. }) {
261 if recent_topics.len() >= max_recent {
262 recent_topics.remove(0);
263 }
264 recent_topics.push(topic);
265 }
266
267 result
268 }
269
270 pub async fn run_once(&self, topic: Option<&str>) -> ContentResult {
275 let chosen_topic = match topic {
276 Some(t) => t.to_string(),
277 None => {
278 if self.topics.is_empty() {
279 return ContentResult::NoTopics;
280 }
281 let mut rng = rand::thread_rng();
282 self.topics
283 .choose(&mut rng)
284 .expect("topics is non-empty")
285 .clone()
286 }
287 };
288
289 if !self.safety.can_post_tweet().await {
291 return ContentResult::RateLimited;
292 }
293
294 self.generate_and_post(&chosen_topic).await
295 }
296
297 async fn run_iteration(
299 &self,
300 recent_topics: &mut Vec<String>,
301 max_recent: usize,
302 rng: &mut impl rand::Rng,
303 ) -> ContentResult {
304 if let Some(result) = self.try_post_scheduled().await {
306 return result;
307 }
308
309 match self.storage.last_tweet_time().await {
311 Ok(Some(last_time)) => {
312 let elapsed = chrono::Utc::now()
313 .signed_duration_since(last_time)
314 .num_seconds()
315 .max(0) as u64;
316
317 if elapsed < self.post_window_secs {
318 return ContentResult::TooSoon {
319 elapsed_secs: elapsed,
320 window_secs: self.post_window_secs,
321 };
322 }
323 }
324 Ok(None) => {
325 }
327 Err(e) => {
328 tracing::warn!(error = %e, "Failed to query last tweet time, proceeding anyway");
329 }
330 }
331
332 if !self.safety.can_post_tweet().await {
334 return ContentResult::RateLimited;
335 }
336
337 let topic = self.pick_topic_epsilon_greedy(recent_topics, rng).await;
339
340 let result = self.generate_and_post(&topic).await;
341
342 if matches!(result, ContentResult::Posted { .. }) {
344 if recent_topics.len() >= max_recent {
345 recent_topics.remove(0);
346 }
347 recent_topics.push(topic);
348 }
349
350 result
351 }
352
353 async fn pick_topic_epsilon_greedy(
362 &self,
363 recent_topics: &mut Vec<String>,
364 rng: &mut impl rand::Rng,
365 ) -> String {
366 if let Some(scorer) = &self.topic_scorer {
367 let roll: f64 = rng.gen();
368 if roll < EXPLOIT_RATIO {
369 if let Ok(top_topics) = scorer.get_top_topics(10).await {
371 let candidates: Vec<&String> = top_topics
373 .iter()
374 .filter(|t| self.topics.contains(t) && !recent_topics.contains(t))
375 .collect();
376
377 if !candidates.is_empty() {
378 let topic = candidates[0].clone();
379 tracing::debug!(topic = %topic, "Epsilon-greedy: exploiting top topic");
380 return topic;
381 }
382 }
383 tracing::debug!("Epsilon-greedy: no top topics available, falling back to random");
385 } else {
386 tracing::debug!("Epsilon-greedy: exploring random topic");
387 }
388 }
389
390 pick_topic(&self.topics, recent_topics, rng)
391 }
392
393 async fn try_post_scheduled(&self) -> Option<ContentResult> {
398 match self.storage.next_scheduled_item().await {
399 Ok(Some((id, content_type, content))) => {
400 tracing::info!(
401 id = id,
402 content_type = %content_type,
403 "Posting scheduled content"
404 );
405
406 let preview = &content[..content.len().min(80)];
407
408 if self.dry_run {
409 tracing::info!(
410 "DRY RUN: Would post scheduled {} (id={}): \"{}\"",
411 content_type,
412 id,
413 preview
414 );
415 let _ = self
416 .storage
417 .log_action(
418 &content_type,
419 "dry_run",
420 &format!("Scheduled id={id}: {preview}"),
421 )
422 .await;
423 } else if content_type == "thread" {
424 match self.post_scheduled_thread(id, &content).await {
426 Ok(()) => {}
427 Err(e) => {
428 return Some(ContentResult::Failed {
429 error: format!("Scheduled thread failed: {e}"),
430 });
431 }
432 }
433 } else if let Err(e) = self.storage.post_tweet("scheduled", &content).await {
434 tracing::error!(error = %e, "Failed to post scheduled content");
435 return Some(ContentResult::Failed {
436 error: format!("Scheduled post failed: {e}"),
437 });
438 } else {
439 let _ = self.storage.mark_scheduled_posted(id, None).await;
440 let _ = self
441 .storage
442 .log_action(
443 &content_type,
444 "success",
445 &format!("Scheduled id={id}: {preview}"),
446 )
447 .await;
448 }
449
450 Some(ContentResult::Posted {
451 topic: format!("scheduled:{id}"),
452 content,
453 })
454 }
455 Ok(None) => None,
456 Err(e) => {
457 tracing::warn!(error = %e, "Failed to check scheduled content");
458 None
459 }
460 }
461 }
462
463 async fn post_scheduled_thread(&self, id: i64, content: &str) -> Result<(), String> {
465 let poster = self.thread_poster.as_ref().ok_or_else(|| {
466 "No thread poster configured — cannot post scheduled threads".to_string()
467 })?;
468
469 let tweets: Vec<String> =
471 if let Some(blocks) = crate::content::deserialize_blocks_from_content(content) {
472 let mut sorted = blocks;
473 sorted.sort_by_key(|b| b.order);
474 sorted.into_iter().map(|b| b.text).collect()
475 } else if let Ok(arr) = serde_json::from_str::<Vec<String>>(content) {
476 arr
477 } else {
478 return Err(format!("Cannot parse thread content for scheduled id={id}"));
479 };
480
481 if tweets.is_empty() {
482 return Err(format!("Scheduled thread id={id} has no tweets"));
483 }
484
485 let mut prev_id: Option<String> = None;
487 for (i, text) in tweets.iter().enumerate() {
488 let result = if let Some(ref reply_to) = prev_id {
489 poster.reply_to_tweet(reply_to, text).await
490 } else {
491 poster.post_tweet(text).await
492 };
493
494 match result {
495 Ok(tweet_id) => prev_id = Some(tweet_id),
496 Err(e) => {
497 tracing::error!(
498 error = %e,
499 index = i,
500 "Scheduled thread id={id} failed at tweet {}/{}",
501 i + 1,
502 tweets.len()
503 );
504 return Err(format!(
505 "Thread failed at tweet {}/{}: {e}",
506 i + 1,
507 tweets.len()
508 ));
509 }
510 }
511 }
512
513 let _ = self
514 .storage
515 .mark_scheduled_posted(id, prev_id.as_deref())
516 .await;
517 let _ = self
518 .storage
519 .log_action(
520 "thread",
521 "success",
522 &format!("Scheduled thread id={id}: {} tweets posted", tweets.len()),
523 )
524 .await;
525
526 Ok(())
527 }
528
529 async fn generate_and_post(&self, topic: &str) -> ContentResult {
531 tracing::info!(topic = %topic, "Generating tweet on topic");
532
533 let content = match self.generator.generate_tweet(topic).await {
535 Ok(text) => text,
536 Err(e) => {
537 return ContentResult::Failed {
538 error: format!("Generation failed: {e}"),
539 }
540 }
541 };
542
543 let content = if crate::content::length::tweet_weighted_len(&content)
545 > crate::content::length::MAX_TWEET_CHARS
546 {
547 tracing::debug!(
549 chars = content.len(),
550 "Generated tweet too long, retrying with shorter instruction"
551 );
552
553 let shorter_topic = format!("{topic} (IMPORTANT: keep under 280 characters)");
554 match self.generator.generate_tweet(&shorter_topic).await {
555 Ok(text)
556 if crate::content::length::tweet_weighted_len(&text)
557 <= crate::content::length::MAX_TWEET_CHARS =>
558 {
559 text
560 }
561 Ok(text) => {
562 tracing::warn!(
564 chars = text.len(),
565 "Retry still too long, truncating at word boundary"
566 );
567 truncate_at_word_boundary(&text, 280)
568 }
569 Err(e) => {
570 tracing::warn!(error = %e, "Retry generation failed, truncating original");
572 truncate_at_word_boundary(&content, 280)
573 }
574 }
575 } else {
576 content
577 };
578
579 if self.dry_run {
580 tracing::info!(
581 "DRY RUN: Would post tweet on topic '{}': \"{}\" ({} chars)",
582 topic,
583 content,
584 content.len()
585 );
586
587 let _ = self
588 .storage
589 .log_action(
590 "tweet",
591 "dry_run",
592 &format!("Topic '{}': {}", topic, truncate_display(&content, 80)),
593 )
594 .await;
595 } else {
596 if let Err(e) = self.storage.post_tweet(topic, &content).await {
597 tracing::error!(error = %e, "Failed to post tweet");
598 let _ = self
599 .storage
600 .log_action("tweet", "failure", &format!("Post failed: {e}"))
601 .await;
602 return ContentResult::Failed {
603 error: e.to_string(),
604 };
605 }
606
607 let _ = self
608 .storage
609 .log_action(
610 "tweet",
611 "success",
612 &format!("Topic '{}': {}", topic, truncate_display(&content, 80)),
613 )
614 .await;
615 }
616
617 ContentResult::Posted {
618 topic: topic.to_string(),
619 content,
620 }
621 }
622}
623
624fn pick_topic(topics: &[String], recent: &mut Vec<String>, rng: &mut impl rand::Rng) -> String {
627 let available: Vec<&String> = topics.iter().filter(|t| !recent.contains(t)).collect();
628
629 if available.is_empty() {
630 recent.clear();
632 topics.choose(rng).expect("topics is non-empty").clone()
633 } else {
634 available
635 .choose(rng)
636 .expect("available is non-empty")
637 .to_string()
638 }
639}
640
641fn truncate_at_word_boundary(s: &str, max_len: usize) -> String {
643 if s.len() <= max_len {
644 return s.to_string();
645 }
646
647 let cutoff = max_len.saturating_sub(3);
649 match s[..cutoff].rfind(' ') {
650 Some(pos) => format!("{}...", &s[..pos]),
651 None => format!("{}...", &s[..cutoff]),
652 }
653}
654
655fn truncate_display(s: &str, max_len: usize) -> String {
657 if s.len() <= max_len {
658 s.to_string()
659 } else {
660 format!("{}...", &s[..max_len])
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667 use crate::automation::ContentLoopError;
668 use std::sync::Mutex;
669
670 struct MockGenerator {
673 response: String,
674 }
675
676 #[async_trait::async_trait]
677 impl TweetGenerator for MockGenerator {
678 async fn generate_tweet(&self, _topic: &str) -> Result<String, ContentLoopError> {
679 Ok(self.response.clone())
680 }
681 }
682
683 struct OverlongGenerator {
684 first_response: String,
685 retry_response: String,
686 call_count: Mutex<usize>,
687 }
688
689 #[async_trait::async_trait]
690 impl TweetGenerator for OverlongGenerator {
691 async fn generate_tweet(&self, _topic: &str) -> Result<String, ContentLoopError> {
692 let mut count = self.call_count.lock().expect("lock");
693 *count += 1;
694 if *count == 1 {
695 Ok(self.first_response.clone())
696 } else {
697 Ok(self.retry_response.clone())
698 }
699 }
700 }
701
702 struct FailingGenerator;
703
704 #[async_trait::async_trait]
705 impl TweetGenerator for FailingGenerator {
706 async fn generate_tweet(&self, _topic: &str) -> Result<String, ContentLoopError> {
707 Err(ContentLoopError::LlmFailure(
708 "model unavailable".to_string(),
709 ))
710 }
711 }
712
713 struct MockSafety {
714 can_tweet: bool,
715 can_thread: bool,
716 }
717
718 #[async_trait::async_trait]
719 impl ContentSafety for MockSafety {
720 async fn can_post_tweet(&self) -> bool {
721 self.can_tweet
722 }
723 async fn can_post_thread(&self) -> bool {
724 self.can_thread
725 }
726 }
727
728 struct MockStorage {
729 last_tweet: Mutex<Option<chrono::DateTime<chrono::Utc>>>,
730 posted_tweets: Mutex<Vec<(String, String)>>,
731 actions: Mutex<Vec<(String, String, String)>>,
732 }
733
734 impl MockStorage {
735 fn new(last_tweet: Option<chrono::DateTime<chrono::Utc>>) -> Self {
736 Self {
737 last_tweet: Mutex::new(last_tweet),
738 posted_tweets: Mutex::new(Vec::new()),
739 actions: Mutex::new(Vec::new()),
740 }
741 }
742
743 fn posted_count(&self) -> usize {
744 self.posted_tweets.lock().expect("lock").len()
745 }
746
747 fn action_count(&self) -> usize {
748 self.actions.lock().expect("lock").len()
749 }
750 }
751
752 #[async_trait::async_trait]
753 impl ContentStorage for MockStorage {
754 async fn last_tweet_time(
755 &self,
756 ) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
757 Ok(*self.last_tweet.lock().expect("lock"))
758 }
759
760 async fn last_thread_time(
761 &self,
762 ) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
763 Ok(None)
764 }
765
766 async fn todays_tweet_times(
767 &self,
768 ) -> Result<Vec<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
769 Ok(Vec::new())
770 }
771
772 async fn post_tweet(&self, topic: &str, content: &str) -> Result<(), ContentLoopError> {
773 self.posted_tweets
774 .lock()
775 .expect("lock")
776 .push((topic.to_string(), content.to_string()));
777 Ok(())
778 }
779
780 async fn create_thread(
781 &self,
782 _topic: &str,
783 _tweet_count: usize,
784 ) -> Result<String, ContentLoopError> {
785 Ok("thread-1".to_string())
786 }
787
788 async fn update_thread_status(
789 &self,
790 _thread_id: &str,
791 _status: &str,
792 _tweet_count: usize,
793 _root_tweet_id: Option<&str>,
794 ) -> Result<(), ContentLoopError> {
795 Ok(())
796 }
797
798 async fn store_thread_tweet(
799 &self,
800 _thread_id: &str,
801 _position: usize,
802 _tweet_id: &str,
803 _content: &str,
804 ) -> Result<(), ContentLoopError> {
805 Ok(())
806 }
807
808 async fn log_action(
809 &self,
810 action_type: &str,
811 status: &str,
812 message: &str,
813 ) -> Result<(), ContentLoopError> {
814 self.actions.lock().expect("lock").push((
815 action_type.to_string(),
816 status.to_string(),
817 message.to_string(),
818 ));
819 Ok(())
820 }
821 }
822
823 fn make_topics() -> Vec<String> {
824 vec![
825 "Rust".to_string(),
826 "CLI tools".to_string(),
827 "Open source".to_string(),
828 "Developer productivity".to_string(),
829 ]
830 }
831
832 #[tokio::test]
835 async fn run_once_posts_tweet() {
836 let storage = Arc::new(MockStorage::new(None));
837 let content = ContentLoop::new(
838 Arc::new(MockGenerator {
839 response: "Great tweet about Rust!".to_string(),
840 }),
841 Arc::new(MockSafety {
842 can_tweet: true,
843 can_thread: true,
844 }),
845 storage.clone(),
846 make_topics(),
847 14400,
848 false,
849 );
850
851 let result = content.run_once(Some("Rust")).await;
852 assert!(matches!(result, ContentResult::Posted { .. }));
853 assert_eq!(storage.posted_count(), 1);
854 }
855
856 #[tokio::test]
857 async fn run_once_dry_run_does_not_post() {
858 let storage = Arc::new(MockStorage::new(None));
859 let content = ContentLoop::new(
860 Arc::new(MockGenerator {
861 response: "Great tweet about Rust!".to_string(),
862 }),
863 Arc::new(MockSafety {
864 can_tweet: true,
865 can_thread: true,
866 }),
867 storage.clone(),
868 make_topics(),
869 14400,
870 true,
871 );
872
873 let result = content.run_once(Some("Rust")).await;
874 assert!(matches!(result, ContentResult::Posted { .. }));
875 assert_eq!(storage.posted_count(), 0); assert_eq!(storage.action_count(), 1); }
878
879 #[tokio::test]
880 async fn run_once_rate_limited() {
881 let content = ContentLoop::new(
882 Arc::new(MockGenerator {
883 response: "tweet".to_string(),
884 }),
885 Arc::new(MockSafety {
886 can_tweet: false,
887 can_thread: true,
888 }),
889 Arc::new(MockStorage::new(None)),
890 make_topics(),
891 14400,
892 false,
893 );
894
895 let result = content.run_once(None).await;
896 assert!(matches!(result, ContentResult::RateLimited));
897 }
898
899 #[tokio::test]
900 async fn run_once_no_topics_returns_no_topics() {
901 let content = ContentLoop::new(
902 Arc::new(MockGenerator {
903 response: "tweet".to_string(),
904 }),
905 Arc::new(MockSafety {
906 can_tweet: true,
907 can_thread: true,
908 }),
909 Arc::new(MockStorage::new(None)),
910 Vec::new(),
911 14400,
912 false,
913 );
914
915 let result = content.run_once(None).await;
916 assert!(matches!(result, ContentResult::NoTopics));
917 }
918
919 #[tokio::test]
920 async fn run_once_generation_failure() {
921 let content = ContentLoop::new(
922 Arc::new(FailingGenerator),
923 Arc::new(MockSafety {
924 can_tweet: true,
925 can_thread: true,
926 }),
927 Arc::new(MockStorage::new(None)),
928 make_topics(),
929 14400,
930 false,
931 );
932
933 let result = content.run_once(Some("Rust")).await;
934 assert!(matches!(result, ContentResult::Failed { .. }));
935 }
936
937 #[tokio::test]
938 async fn run_iteration_skips_when_too_soon() {
939 let now = chrono::Utc::now();
940 let last_tweet = now - chrono::Duration::hours(1);
942 let storage = Arc::new(MockStorage::new(Some(last_tweet)));
943
944 let content = ContentLoop::new(
945 Arc::new(MockGenerator {
946 response: "tweet".to_string(),
947 }),
948 Arc::new(MockSafety {
949 can_tweet: true,
950 can_thread: true,
951 }),
952 storage,
953 make_topics(),
954 14400, false,
956 );
957
958 let mut recent = Vec::new();
959 let mut rng = rand::thread_rng();
960 let result = content.run_iteration(&mut recent, 3, &mut rng).await;
961 assert!(matches!(result, ContentResult::TooSoon { .. }));
962 }
963
964 #[tokio::test]
965 async fn run_iteration_posts_when_window_elapsed() {
966 let now = chrono::Utc::now();
967 let last_tweet = now - chrono::Duration::hours(5);
969 let storage = Arc::new(MockStorage::new(Some(last_tweet)));
970
971 let content = ContentLoop::new(
972 Arc::new(MockGenerator {
973 response: "Fresh tweet!".to_string(),
974 }),
975 Arc::new(MockSafety {
976 can_tweet: true,
977 can_thread: true,
978 }),
979 storage.clone(),
980 make_topics(),
981 14400,
982 false,
983 );
984
985 let mut recent = Vec::new();
986 let mut rng = rand::thread_rng();
987 let result = content.run_iteration(&mut recent, 3, &mut rng).await;
988 assert!(matches!(result, ContentResult::Posted { .. }));
989 assert_eq!(storage.posted_count(), 1);
990 assert_eq!(recent.len(), 1);
991 }
992
993 #[tokio::test]
994 async fn overlong_tweet_gets_truncated() {
995 let long_text = "a ".repeat(200); let content = ContentLoop::new(
997 Arc::new(OverlongGenerator {
998 first_response: long_text.clone(),
999 retry_response: long_text, call_count: Mutex::new(0),
1001 }),
1002 Arc::new(MockSafety {
1003 can_tweet: true,
1004 can_thread: true,
1005 }),
1006 Arc::new(MockStorage::new(None)),
1007 make_topics(),
1008 14400,
1009 true,
1010 );
1011
1012 let result = content.run_once(Some("Rust")).await;
1013 if let ContentResult::Posted { content, .. } = result {
1014 assert!(content.len() <= 280);
1015 } else {
1016 panic!("Expected Posted result");
1017 }
1018 }
1019
1020 #[test]
1021 fn truncate_at_word_boundary_short() {
1022 let result = truncate_at_word_boundary("Hello world", 280);
1023 assert_eq!(result, "Hello world");
1024 }
1025
1026 #[test]
1027 fn truncate_at_word_boundary_long() {
1028 let text = "The quick brown fox jumps over the lazy dog and more words here";
1029 let result = truncate_at_word_boundary(text, 30);
1030 assert!(result.len() <= 30);
1031 assert!(result.ends_with("..."));
1032 }
1033
1034 #[test]
1035 fn pick_topic_avoids_recent() {
1036 let topics = make_topics();
1037 let mut recent = vec!["Rust".to_string(), "CLI tools".to_string()];
1038 let mut rng = rand::thread_rng();
1039
1040 for _ in 0..20 {
1041 let topic = pick_topic(&topics, &mut recent, &mut rng);
1042 assert_ne!(topic, "Rust");
1044 assert_ne!(topic, "CLI tools");
1045 }
1046 }
1047
1048 #[test]
1049 fn pick_topic_clears_when_all_recent() {
1050 let topics = make_topics();
1051 let mut recent = topics.clone();
1052 let mut rng = rand::thread_rng();
1053
1054 let topic = pick_topic(&topics, &mut recent, &mut rng);
1056 assert!(topics.contains(&topic));
1057 assert!(recent.is_empty()); }
1059
1060 #[test]
1061 fn truncate_display_short() {
1062 assert_eq!(truncate_display("hello", 10), "hello");
1063 }
1064
1065 #[test]
1066 fn truncate_display_long() {
1067 let result = truncate_display("hello world this is long", 10);
1068 assert_eq!(result, "hello worl...");
1069 }
1070
1071 struct MockTopicScorer {
1074 top_topics: Vec<String>,
1075 }
1076
1077 #[async_trait::async_trait]
1078 impl TopicScorer for MockTopicScorer {
1079 async fn get_top_topics(&self, _limit: u32) -> Result<Vec<String>, ContentLoopError> {
1080 Ok(self.top_topics.clone())
1081 }
1082 }
1083
1084 struct FailingTopicScorer;
1085
1086 #[async_trait::async_trait]
1087 impl TopicScorer for FailingTopicScorer {
1088 async fn get_top_topics(&self, _limit: u32) -> Result<Vec<String>, ContentLoopError> {
1089 Err(ContentLoopError::StorageError("db error".to_string()))
1090 }
1091 }
1092
1093 #[tokio::test]
1094 async fn epsilon_greedy_exploits_top_topic() {
1095 let storage = Arc::new(MockStorage::new(None));
1096 let scorer = Arc::new(MockTopicScorer {
1097 top_topics: vec!["Rust".to_string()],
1098 });
1099
1100 let content = ContentLoop::new(
1101 Arc::new(MockGenerator {
1102 response: "tweet".to_string(),
1103 }),
1104 Arc::new(MockSafety {
1105 can_tweet: true,
1106 can_thread: true,
1107 }),
1108 storage,
1109 make_topics(),
1110 14400,
1111 false,
1112 )
1113 .with_topic_scorer(scorer);
1114
1115 let mut recent = Vec::new();
1116 let mut rng = FirstCallRng::low_roll();
1118
1119 let topic = content
1120 .pick_topic_epsilon_greedy(&mut recent, &mut rng)
1121 .await;
1122 assert_eq!(topic, "Rust");
1123 }
1124
1125 #[tokio::test]
1126 async fn epsilon_greedy_explores_when_roll_high() {
1127 let storage = Arc::new(MockStorage::new(None));
1128 let scorer = Arc::new(MockTopicScorer {
1129 top_topics: vec!["Rust".to_string()],
1130 });
1131
1132 let content = ContentLoop::new(
1133 Arc::new(MockGenerator {
1134 response: "tweet".to_string(),
1135 }),
1136 Arc::new(MockSafety {
1137 can_tweet: true,
1138 can_thread: true,
1139 }),
1140 storage,
1141 make_topics(),
1142 14400,
1143 false,
1144 )
1145 .with_topic_scorer(scorer);
1146
1147 let mut recent = Vec::new();
1148 let mut rng = FirstCallRng::high_roll();
1150
1151 let topic = content
1152 .pick_topic_epsilon_greedy(&mut recent, &mut rng)
1153 .await;
1154 assert!(make_topics().contains(&topic));
1155 }
1156
1157 #[tokio::test]
1158 async fn epsilon_greedy_falls_back_on_scorer_error() {
1159 let storage = Arc::new(MockStorage::new(None));
1160 let scorer = Arc::new(FailingTopicScorer);
1161
1162 let content = ContentLoop::new(
1163 Arc::new(MockGenerator {
1164 response: "tweet".to_string(),
1165 }),
1166 Arc::new(MockSafety {
1167 can_tweet: true,
1168 can_thread: true,
1169 }),
1170 storage,
1171 make_topics(),
1172 14400,
1173 false,
1174 )
1175 .with_topic_scorer(scorer);
1176
1177 let mut recent = Vec::new();
1178 let mut rng = FirstCallRng::low_roll();
1180
1181 let topic = content
1182 .pick_topic_epsilon_greedy(&mut recent, &mut rng)
1183 .await;
1184 assert!(make_topics().contains(&topic));
1185 }
1186
1187 #[tokio::test]
1188 async fn epsilon_greedy_without_scorer_picks_random() {
1189 let storage = Arc::new(MockStorage::new(None));
1190
1191 let content = ContentLoop::new(
1192 Arc::new(MockGenerator {
1193 response: "tweet".to_string(),
1194 }),
1195 Arc::new(MockSafety {
1196 can_tweet: true,
1197 can_thread: true,
1198 }),
1199 storage,
1200 make_topics(),
1201 14400,
1202 false,
1203 );
1204
1205 let mut recent = Vec::new();
1206 let mut rng = rand::thread_rng();
1207
1208 let topic = content
1209 .pick_topic_epsilon_greedy(&mut recent, &mut rng)
1210 .await;
1211 assert!(make_topics().contains(&topic));
1212 }
1213
1214 struct FirstCallRng {
1219 first_u64: Option<u64>,
1220 inner: rand::rngs::ThreadRng,
1221 }
1222
1223 impl FirstCallRng {
1224 fn low_roll() -> Self {
1226 Self {
1227 first_u64: Some(0),
1228 inner: rand::thread_rng(),
1229 }
1230 }
1231
1232 fn high_roll() -> Self {
1234 Self {
1235 first_u64: Some(u64::MAX),
1236 inner: rand::thread_rng(),
1237 }
1238 }
1239 }
1240
1241 impl rand::RngCore for FirstCallRng {
1242 fn next_u32(&mut self) -> u32 {
1243 self.inner.next_u32()
1244 }
1245 fn next_u64(&mut self) -> u64 {
1246 if let Some(val) = self.first_u64.take() {
1247 val
1248 } else {
1249 self.inner.next_u64()
1250 }
1251 }
1252 fn fill_bytes(&mut self, dest: &mut [u8]) {
1253 self.inner.fill_bytes(dest);
1254 }
1255 fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand::Error> {
1256 self.inner.try_fill_bytes(dest)
1257 }
1258 }
1259}