Skip to main content

tuitbot_core/automation/
content_loop.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
7use 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
17/// Fraction of the time to exploit top-performing topics (vs. explore random ones).
18const EXPLOIT_RATIO: f64 = 0.8;
19
20/// Content loop that generates and posts original educational tweets.
21pub 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/// Result of a content generation attempt.
33#[derive(Debug)]
34pub enum ContentResult {
35    /// Tweet was posted (or would be in dry-run).
36    Posted { topic: String, content: String },
37    /// Skipped because not enough time has elapsed since last tweet.
38    TooSoon { elapsed_secs: u64, window_secs: u64 },
39    /// Skipped due to daily tweet rate limit.
40    RateLimited,
41    /// No topics configured.
42    NoTopics,
43    /// Generation failed.
44    Failed { error: String },
45}
46
47impl ContentLoop {
48    /// Create a new content loop.
49    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    /// Set a topic scorer for epsilon-greedy topic selection.
70    ///
71    /// When set, 80% of the time the loop picks from top-performing topics
72    /// (exploit), and 20% of the time it picks a random topic (explore).
73    pub fn with_topic_scorer(mut self, scorer: Arc<dyn TopicScorer>) -> Self {
74        self.topic_scorer = Some(scorer);
75        self
76    }
77
78    /// Set a thread poster for posting scheduled threads as reply chains.
79    pub fn with_thread_poster(mut self, poster: Arc<dyn ThreadPoster>) -> Self {
80        self.thread_poster = Some(poster);
81        self
82    }
83
84    /// Run the continuous content loop until cancellation.
85    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                // Slot-based scheduling: post at preferred times
125                let sched = schedule.as_ref().expect("slot_mode requires schedule");
126
127                // Query today's post times from storage
128                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                        // In slot mode, skip the elapsed-time check — post directly
155                        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                        // All slots used today — sleep until next active day
162                        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                                // Currently active but all slots used — sleep 1 hour and recheck
169                                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                // Interval-based scheduling (existing behavior)
189                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    /// Log the result of a content iteration.
205    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    /// Run a single iteration in slot mode (skips elapsed-time check).
238    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        // Check for manually scheduled content due for posting
245        if let Some(result) = self.try_post_scheduled().await {
246            return result;
247        }
248
249        // Check safety (daily tweet limit)
250        if !self.safety.can_post_tweet().await {
251            return ContentResult::RateLimited;
252        }
253
254        // Pick a topic using epsilon-greedy if scorer is available
255        let topic = self.pick_topic_epsilon_greedy(recent_topics, rng).await;
256
257        let result = self.generate_and_post(&topic).await;
258
259        // Update recent_topics on success
260        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    /// Run a single content generation (for CLI `tuitbot post` command).
271    ///
272    /// If `topic` is provided, uses that topic. Otherwise picks a random
273    /// topic from the configured list.
274    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        // Skip window check for single-shot mode, but still check safety
290        if !self.safety.can_post_tweet().await {
291            return ContentResult::RateLimited;
292        }
293
294        self.generate_and_post(&chosen_topic).await
295    }
296
297    /// Run a single iteration of the continuous loop.
298    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        // Check for manually scheduled content due for posting
305        if let Some(result) = self.try_post_scheduled().await {
306            return result;
307        }
308
309        // Check elapsed time since last tweet
310        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                // No previous tweets -- proceed
326            }
327            Err(e) => {
328                tracing::warn!(error = %e, "Failed to query last tweet time, proceeding anyway");
329            }
330        }
331
332        // Check safety (daily tweet limit)
333        if !self.safety.can_post_tweet().await {
334            return ContentResult::RateLimited;
335        }
336
337        // Pick a topic using epsilon-greedy if scorer is available
338        let topic = self.pick_topic_epsilon_greedy(recent_topics, rng).await;
339
340        let result = self.generate_and_post(&topic).await;
341
342        // Update recent_topics on success
343        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    /// Pick a topic using epsilon-greedy selection.
354    ///
355    /// If a topic scorer is available:
356    /// - 80% of the time: pick from top-performing topics (exploit)
357    /// - 20% of the time: pick a random topic (explore)
358    ///
359    /// Falls back to uniform random selection if no scorer is set or
360    /// if the scorer returns no data.
361    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                // Exploit: try to pick from top-performing topics
370                if let Ok(top_topics) = scorer.get_top_topics(10).await {
371                    // Filter to topics that are in our configured list and not recent
372                    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                // Fall through to random if no top topics match
384                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    /// Check for scheduled content due for posting and post it if found.
394    ///
395    /// Returns `Some(ContentResult)` if a scheduled item was handled,
396    /// `None` if no scheduled items are due.
397    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                    // Post thread as a reply chain.
425                    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    /// Post a scheduled thread as a reply chain using the `ThreadPoster`.
464    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        // Parse blocks from stored content (versioned JSON or legacy string array).
470        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        // Post first tweet, then reply chain.
486        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    /// Generate a tweet and post it (or print in dry-run mode).
530    async fn generate_and_post(&self, topic: &str) -> ContentResult {
531        tracing::info!(topic = %topic, "Generating tweet on topic");
532
533        // Generate tweet
534        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        // Validate length (280 char limit, URL-aware)
544        let content = if crate::content::length::tweet_weighted_len(&content)
545            > crate::content::length::MAX_TWEET_CHARS
546        {
547            // Retry once with explicit shorter instruction
548            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                    // Truncate at word boundary
563                    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                    // Use original but truncated
571                    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
624/// Pick a topic that is not in the recent list.
625/// If all topics are recent, clear the list and pick any.
626fn 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        // All topics recently used -- clear and pick any
631        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
641/// Truncate content at a word boundary, fitting within max_len characters.
642fn truncate_at_word_boundary(s: &str, max_len: usize) -> String {
643    if s.len() <= max_len {
644        return s.to_string();
645    }
646
647    // Find last space before max_len - 3 (for "...")
648    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
655/// Truncate a string for display purposes.
656fn 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    // --- Mock implementations ---
671
672    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    // --- Tests ---
833
834    #[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); // Not posted in dry-run
876        assert_eq!(storage.action_count(), 1); // Action logged
877    }
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        // Last tweet was 1 hour ago, window is 4 hours
941        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, // 4 hours
955            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        // Last tweet was 5 hours ago, window is 4 hours
968        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); // 400 chars
996        let content = ContentLoop::new(
997            Arc::new(OverlongGenerator {
998                first_response: long_text.clone(),
999                retry_response: long_text, // retry also too long
1000                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            // Should not pick Rust or CLI tools
1043            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        // Should clear recent and pick any topic
1055        let topic = pick_topic(&topics, &mut recent, &mut rng);
1056        assert!(topics.contains(&topic));
1057        assert!(recent.is_empty()); // Cleared
1058    }
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    // --- Epsilon-greedy tests ---
1072
1073    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        // Low roll => exploit. Use thread_rng for the pick_topic fallback path.
1117        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        // High roll => explore, delegates to pick_topic with real rng
1149        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        // Low roll => exploit, but scorer fails => falls back to random
1179        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    /// RNG wrapper that overrides only the first `next_u64()` call,
1215    /// then delegates everything to a real ThreadRng. This lets us
1216    /// control the initial `gen::<f64>()` roll without breaking
1217    /// subsequent `choose()` / `gen_range()` calls.
1218    struct FirstCallRng {
1219        first_u64: Option<u64>,
1220        inner: rand::rngs::ThreadRng,
1221    }
1222
1223    impl FirstCallRng {
1224        /// Create an RNG whose first `gen::<f64>()` returns ~0.0 (exploit).
1225        fn low_roll() -> Self {
1226            Self {
1227                first_u64: Some(0),
1228                inner: rand::thread_rng(),
1229            }
1230        }
1231
1232        /// Create an RNG whose first `gen::<f64>()` returns ~1.0 (explore).
1233        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}