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