Skip to main content

tuitbot_core/automation/
discovery_loop.rs

1//! Tweet discovery loop.
2//!
3//! Searches X using configured keywords, scores each tweet with the
4//! scoring engine, filters by threshold, generates replies for
5//! qualifying tweets, and posts them through the posting queue.
6//! Rotates keywords across iterations to distribute API usage.
7
8use super::loop_helpers::{
9    ConsecutiveErrorTracker, LoopError, LoopStorage, LoopTweet, PostSender, ReplyGenerator,
10    SafetyChecker, TweetScorer, TweetSearcher,
11};
12use super::schedule::{schedule_gate, ActiveSchedule};
13use super::scheduler::LoopScheduler;
14use std::sync::Arc;
15use std::time::Duration;
16use tokio_util::sync::CancellationToken;
17
18/// Discovery loop that finds and replies to relevant tweets.
19pub struct DiscoveryLoop {
20    searcher: Arc<dyn TweetSearcher>,
21    scorer: Arc<dyn TweetScorer>,
22    generator: Arc<dyn ReplyGenerator>,
23    safety: Arc<dyn SafetyChecker>,
24    storage: Arc<dyn LoopStorage>,
25    poster: Arc<dyn PostSender>,
26    keywords: Vec<String>,
27    threshold: f32,
28    dry_run: bool,
29}
30
31/// Result of processing a single discovered tweet.
32#[derive(Debug)]
33pub enum DiscoveryResult {
34    /// Reply was sent (or would be sent in dry-run).
35    Replied {
36        tweet_id: String,
37        author: String,
38        score: f32,
39        reply_text: String,
40    },
41    /// Tweet scored below threshold.
42    BelowThreshold { tweet_id: String, score: f32 },
43    /// Tweet was skipped (safety check, already exists).
44    Skipped { tweet_id: String, reason: String },
45    /// Processing failed for this tweet.
46    Failed { tweet_id: String, error: String },
47}
48
49/// Summary of a discovery iteration.
50#[derive(Debug, Default)]
51pub struct DiscoverySummary {
52    /// Total tweets found across all keywords searched.
53    pub tweets_found: usize,
54    /// Tweets that scored above threshold.
55    pub qualifying: usize,
56    /// Replies sent (or would be sent in dry-run).
57    pub replied: usize,
58    /// Tweets skipped (safety, dedup, below threshold).
59    pub skipped: usize,
60    /// Tweets that failed processing.
61    pub failed: usize,
62}
63
64impl DiscoveryLoop {
65    /// Create a new discovery loop.
66    #[allow(clippy::too_many_arguments)]
67    pub fn new(
68        searcher: Arc<dyn TweetSearcher>,
69        scorer: Arc<dyn TweetScorer>,
70        generator: Arc<dyn ReplyGenerator>,
71        safety: Arc<dyn SafetyChecker>,
72        storage: Arc<dyn LoopStorage>,
73        poster: Arc<dyn PostSender>,
74        keywords: Vec<String>,
75        threshold: f32,
76        dry_run: bool,
77    ) -> Self {
78        Self {
79            searcher,
80            scorer,
81            generator,
82            safety,
83            storage,
84            poster,
85            keywords,
86            threshold,
87            dry_run,
88        }
89    }
90
91    /// Run the continuous discovery loop until cancellation.
92    ///
93    /// Rotates through keywords across iterations to distribute API usage.
94    pub async fn run(
95        &self,
96        cancel: CancellationToken,
97        scheduler: LoopScheduler,
98        schedule: Option<Arc<ActiveSchedule>>,
99    ) {
100        tracing::info!(
101            dry_run = self.dry_run,
102            keywords = self.keywords.len(),
103            threshold = self.threshold,
104            "Discovery loop started"
105        );
106
107        if self.keywords.is_empty() {
108            tracing::warn!("No keywords configured, discovery loop has nothing to search");
109            cancel.cancelled().await;
110            return;
111        }
112
113        let mut error_tracker = ConsecutiveErrorTracker::new(10, Duration::from_secs(300));
114        let mut keyword_index = 0usize;
115
116        loop {
117            if cancel.is_cancelled() {
118                break;
119            }
120
121            if !schedule_gate(&schedule, &cancel).await {
122                break;
123            }
124
125            // Select next keyword (round-robin)
126            let keyword = &self.keywords[keyword_index % self.keywords.len()];
127            keyword_index += 1;
128
129            match self.search_and_process(keyword, None).await {
130                Ok((_results, summary)) => {
131                    error_tracker.record_success();
132                    if summary.tweets_found > 0 {
133                        tracing::info!(
134                            keyword = %keyword,
135                            found = summary.tweets_found,
136                            qualifying = summary.qualifying,
137                            replied = summary.replied,
138                            "Discovery iteration complete"
139                        );
140                    }
141                }
142                Err(e) => {
143                    let should_pause = error_tracker.record_error();
144                    tracing::warn!(
145                        keyword = %keyword,
146                        error = %e,
147                        consecutive_errors = error_tracker.count(),
148                        "Discovery iteration failed"
149                    );
150
151                    if should_pause {
152                        tracing::warn!(
153                            pause_secs = error_tracker.pause_duration().as_secs(),
154                            "Pausing discovery loop due to consecutive errors"
155                        );
156                        tokio::select! {
157                            _ = cancel.cancelled() => break,
158                            _ = tokio::time::sleep(error_tracker.pause_duration()) => {},
159                        }
160                        error_tracker.reset();
161                        continue;
162                    }
163
164                    if let LoopError::RateLimited { retry_after } = &e {
165                        let backoff = super::loop_helpers::rate_limit_backoff(*retry_after, 0);
166                        tokio::select! {
167                            _ = cancel.cancelled() => break,
168                            _ = tokio::time::sleep(backoff) => {},
169                        }
170                        continue;
171                    }
172                }
173            }
174
175            tokio::select! {
176                _ = cancel.cancelled() => break,
177                _ = scheduler.tick() => {},
178            }
179        }
180
181        tracing::info!("Discovery loop stopped");
182    }
183
184    /// Run a single-shot discovery across all keywords.
185    ///
186    /// Used by the CLI `tuitbot discover` command. Searches all keywords
187    /// (not rotating) and returns all results sorted by score descending.
188    pub async fn run_once(
189        &self,
190        limit: Option<usize>,
191    ) -> Result<(Vec<DiscoveryResult>, DiscoverySummary), LoopError> {
192        let mut all_results = Vec::new();
193        let mut summary = DiscoverySummary::default();
194        let mut total_processed = 0usize;
195        let mut last_error: Option<LoopError> = None;
196        let mut any_success = false;
197
198        for keyword in &self.keywords {
199            if let Some(max) = limit {
200                if total_processed >= max {
201                    break;
202                }
203            }
204
205            let remaining = limit.map(|max| max.saturating_sub(total_processed));
206            match self.search_and_process(keyword, remaining).await {
207                Ok((results, iter_summary)) => {
208                    any_success = true;
209                    summary.tweets_found += iter_summary.tweets_found;
210                    summary.qualifying += iter_summary.qualifying;
211                    summary.replied += iter_summary.replied;
212                    summary.skipped += iter_summary.skipped;
213                    summary.failed += iter_summary.failed;
214                    total_processed += iter_summary.tweets_found;
215                    all_results.extend(results);
216                }
217                Err(e) => {
218                    tracing::warn!(keyword = %keyword, error = %e, "Search failed for keyword");
219                    last_error = Some(e);
220                }
221            }
222        }
223
224        // If every keyword failed, surface the last error instead of
225        // reporting a misleading empty success.
226        if !any_success {
227            if let Some(err) = last_error {
228                return Err(err);
229            }
230        }
231
232        Ok((all_results, summary))
233    }
234
235    /// Search for a single keyword and process all results.
236    async fn search_and_process(
237        &self,
238        keyword: &str,
239        limit: Option<usize>,
240    ) -> Result<(Vec<DiscoveryResult>, DiscoverySummary), LoopError> {
241        tracing::info!(keyword = %keyword, "Searching keyword");
242        let tweets = self.searcher.search_tweets(keyword).await?;
243
244        let mut summary = DiscoverySummary {
245            tweets_found: tweets.len(),
246            ..Default::default()
247        };
248
249        let to_process = match limit {
250            Some(n) => &tweets[..tweets.len().min(n)],
251            None => &tweets,
252        };
253
254        let mut results = Vec::with_capacity(to_process.len());
255
256        for tweet in to_process {
257            let result = self.process_tweet(tweet, keyword).await;
258
259            match &result {
260                DiscoveryResult::Replied { .. } => {
261                    summary.qualifying += 1;
262                    summary.replied += 1;
263                }
264                DiscoveryResult::BelowThreshold { .. } => {
265                    summary.skipped += 1;
266                }
267                DiscoveryResult::Skipped { .. } => {
268                    summary.skipped += 1;
269                }
270                DiscoveryResult::Failed { .. } => {
271                    summary.failed += 1;
272                }
273            }
274
275            results.push(result);
276        }
277
278        Ok((results, summary))
279    }
280
281    /// Process a single discovered tweet: dedup, score, generate reply, post.
282    async fn process_tweet(&self, tweet: &LoopTweet, keyword: &str) -> DiscoveryResult {
283        // Check if already discovered (dedup)
284        match self.storage.tweet_exists(&tweet.id).await {
285            Ok(true) => {
286                tracing::debug!(tweet_id = %tweet.id, "Tweet already discovered, skipping");
287                return DiscoveryResult::Skipped {
288                    tweet_id: tweet.id.clone(),
289                    reason: "already discovered".to_string(),
290                };
291            }
292            Ok(false) => {}
293            Err(e) => {
294                tracing::warn!(tweet_id = %tweet.id, error = %e, "Failed to check tweet existence");
295                // Continue anyway -- best effort dedup
296            }
297        }
298
299        // Score the tweet
300        let score_result = self.scorer.score(tweet);
301
302        // Store discovered tweet (even if below threshold, useful for analytics)
303        if let Err(e) = self
304            .storage
305            .store_discovered_tweet(tweet, score_result.total, keyword)
306            .await
307        {
308            tracing::warn!(tweet_id = %tweet.id, error = %e, "Failed to store discovered tweet");
309        }
310
311        // Check threshold
312        if !score_result.meets_threshold {
313            tracing::debug!(
314                tweet_id = %tweet.id,
315                score = score_result.total,
316                threshold = self.threshold,
317                "Tweet scored below threshold, skipping"
318            );
319            return DiscoveryResult::BelowThreshold {
320                tweet_id: tweet.id.clone(),
321                score: score_result.total,
322            };
323        }
324
325        // Safety checks
326        if self.safety.has_replied_to(&tweet.id).await {
327            return DiscoveryResult::Skipped {
328                tweet_id: tweet.id.clone(),
329                reason: "already replied".to_string(),
330            };
331        }
332
333        if !self.safety.can_reply().await {
334            return DiscoveryResult::Skipped {
335                tweet_id: tweet.id.clone(),
336                reason: "rate limited".to_string(),
337            };
338        }
339
340        // Generate reply with vault context (product mention always on for discovery)
341        let reply_output = match self
342            .generator
343            .generate_reply_with_rag(&tweet.text, &tweet.author_username, true)
344            .await
345        {
346            Ok(output) => output,
347            Err(e) => {
348                tracing::error!(
349                    tweet_id = %tweet.id,
350                    error = %e,
351                    "Failed to generate reply"
352                );
353                return DiscoveryResult::Failed {
354                    tweet_id: tweet.id.clone(),
355                    error: e.to_string(),
356                };
357            }
358        };
359        let reply_text = reply_output.text;
360
361        tracing::info!(
362            author = %tweet.author_username,
363            score = format!("{:.0}", score_result.total),
364            "Posted reply to @{}",
365            tweet.author_username,
366        );
367
368        if self.dry_run {
369            tracing::info!(
370                "DRY RUN: Tweet {} by @{} scored {:.0}/100 -- Would reply: \"{}\"",
371                tweet.id,
372                tweet.author_username,
373                score_result.total,
374                reply_text
375            );
376
377            let _ = self
378                .storage
379                .log_action(
380                    "discovery_reply",
381                    "dry_run",
382                    &format!(
383                        "Score {:.0}, reply to @{}: {}",
384                        score_result.total,
385                        tweet.author_username,
386                        truncate(&reply_text, 50)
387                    ),
388                )
389                .await;
390        } else {
391            if let Err(e) = self.poster.send_reply(&tweet.id, &reply_text).await {
392                tracing::error!(tweet_id = %tweet.id, error = %e, "Failed to send reply");
393                return DiscoveryResult::Failed {
394                    tweet_id: tweet.id.clone(),
395                    error: e.to_string(),
396                };
397            }
398
399            if let Err(e) = self.safety.record_reply(&tweet.id, &reply_text).await {
400                tracing::warn!(tweet_id = %tweet.id, error = %e, "Failed to record reply");
401            }
402
403            let _ = self
404                .storage
405                .log_action(
406                    "discovery_reply",
407                    "success",
408                    &format!(
409                        "Score {:.0}, replied to @{}: {}",
410                        score_result.total,
411                        tweet.author_username,
412                        truncate(&reply_text, 50)
413                    ),
414                )
415                .await;
416        }
417
418        DiscoveryResult::Replied {
419            tweet_id: tweet.id.clone(),
420            author: tweet.author_username.clone(),
421            score: score_result.total,
422            reply_text,
423        }
424    }
425}
426
427/// Truncate a string for display.
428fn truncate(s: &str, max_len: usize) -> String {
429    if s.len() <= max_len {
430        s.to_string()
431    } else {
432        format!("{}...", &s[..max_len])
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::automation::ScoreResult;
440    use std::sync::Mutex;
441
442    // --- Mock implementations ---
443
444    struct MockSearcher {
445        results: Vec<LoopTweet>,
446    }
447
448    #[async_trait::async_trait]
449    impl TweetSearcher for MockSearcher {
450        async fn search_tweets(&self, _query: &str) -> Result<Vec<LoopTweet>, LoopError> {
451            Ok(self.results.clone())
452        }
453    }
454
455    struct FailingSearcher;
456
457    #[async_trait::async_trait]
458    impl TweetSearcher for FailingSearcher {
459        async fn search_tweets(&self, _query: &str) -> Result<Vec<LoopTweet>, LoopError> {
460            Err(LoopError::RateLimited {
461                retry_after: Some(60),
462            })
463        }
464    }
465
466    struct MockScorer {
467        score: f32,
468        meets_threshold: bool,
469    }
470
471    impl TweetScorer for MockScorer {
472        fn score(&self, _tweet: &LoopTweet) -> ScoreResult {
473            ScoreResult {
474                total: self.score,
475                meets_threshold: self.meets_threshold,
476                matched_keywords: vec!["test".to_string()],
477            }
478        }
479    }
480
481    struct MockGenerator {
482        reply: String,
483    }
484
485    #[async_trait::async_trait]
486    impl ReplyGenerator for MockGenerator {
487        async fn generate_reply(
488            &self,
489            _tweet_text: &str,
490            _author: &str,
491            _mention_product: bool,
492        ) -> Result<String, LoopError> {
493            Ok(self.reply.clone())
494        }
495    }
496
497    struct MockSafety {
498        can_reply: bool,
499        replied_ids: Mutex<Vec<String>>,
500    }
501
502    impl MockSafety {
503        fn new(can_reply: bool) -> Self {
504            Self {
505                can_reply,
506                replied_ids: Mutex::new(Vec::new()),
507            }
508        }
509    }
510
511    #[async_trait::async_trait]
512    impl SafetyChecker for MockSafety {
513        async fn can_reply(&self) -> bool {
514            self.can_reply
515        }
516        async fn has_replied_to(&self, tweet_id: &str) -> bool {
517            self.replied_ids
518                .lock()
519                .expect("lock")
520                .contains(&tweet_id.to_string())
521        }
522        async fn record_reply(&self, tweet_id: &str, _content: &str) -> Result<(), LoopError> {
523            self.replied_ids
524                .lock()
525                .expect("lock")
526                .push(tweet_id.to_string());
527            Ok(())
528        }
529    }
530
531    struct MockStorage {
532        existing_ids: Mutex<Vec<String>>,
533        discovered: Mutex<Vec<String>>,
534        actions: Mutex<Vec<(String, String, String)>>,
535    }
536
537    impl MockStorage {
538        fn new() -> Self {
539            Self {
540                existing_ids: Mutex::new(Vec::new()),
541                discovered: Mutex::new(Vec::new()),
542                actions: Mutex::new(Vec::new()),
543            }
544        }
545    }
546
547    #[async_trait::async_trait]
548    impl LoopStorage for MockStorage {
549        async fn get_cursor(&self, _key: &str) -> Result<Option<String>, LoopError> {
550            Ok(None)
551        }
552        async fn set_cursor(&self, _key: &str, _value: &str) -> Result<(), LoopError> {
553            Ok(())
554        }
555        async fn tweet_exists(&self, tweet_id: &str) -> Result<bool, LoopError> {
556            Ok(self
557                .existing_ids
558                .lock()
559                .expect("lock")
560                .contains(&tweet_id.to_string()))
561        }
562        async fn store_discovered_tweet(
563            &self,
564            tweet: &LoopTweet,
565            _score: f32,
566            _keyword: &str,
567        ) -> Result<(), LoopError> {
568            self.discovered.lock().expect("lock").push(tweet.id.clone());
569            Ok(())
570        }
571        async fn log_action(
572            &self,
573            action_type: &str,
574            status: &str,
575            message: &str,
576        ) -> Result<(), LoopError> {
577            self.actions.lock().expect("lock").push((
578                action_type.to_string(),
579                status.to_string(),
580                message.to_string(),
581            ));
582            Ok(())
583        }
584    }
585
586    struct MockPoster {
587        sent: Mutex<Vec<(String, String)>>,
588    }
589
590    impl MockPoster {
591        fn new() -> Self {
592            Self {
593                sent: Mutex::new(Vec::new()),
594            }
595        }
596        fn sent_count(&self) -> usize {
597            self.sent.lock().expect("lock").len()
598        }
599    }
600
601    #[async_trait::async_trait]
602    impl PostSender for MockPoster {
603        async fn send_reply(&self, tweet_id: &str, content: &str) -> Result<(), LoopError> {
604            self.sent
605                .lock()
606                .expect("lock")
607                .push((tweet_id.to_string(), content.to_string()));
608            Ok(())
609        }
610    }
611
612    fn test_tweet(id: &str, author: &str) -> LoopTweet {
613        LoopTweet {
614            id: id.to_string(),
615            text: format!("Test tweet about rust from @{author}"),
616            author_id: format!("uid_{author}"),
617            author_username: author.to_string(),
618            author_followers: 5000,
619            created_at: "2026-01-01T00:00:00Z".to_string(),
620            likes: 20,
621            retweets: 5,
622            replies: 3,
623        }
624    }
625
626    fn build_loop(
627        tweets: Vec<LoopTweet>,
628        score: f32,
629        meets_threshold: bool,
630        dry_run: bool,
631    ) -> (DiscoveryLoop, Arc<MockPoster>, Arc<MockStorage>) {
632        let poster = Arc::new(MockPoster::new());
633        let storage = Arc::new(MockStorage::new());
634        let discovery = DiscoveryLoop::new(
635            Arc::new(MockSearcher { results: tweets }),
636            Arc::new(MockScorer {
637                score,
638                meets_threshold,
639            }),
640            Arc::new(MockGenerator {
641                reply: "Great insight!".to_string(),
642            }),
643            Arc::new(MockSafety::new(true)),
644            storage.clone(),
645            poster.clone(),
646            vec!["rust".to_string(), "cli".to_string()],
647            70.0,
648            dry_run,
649        );
650        (discovery, poster, storage)
651    }
652
653    // --- Tests ---
654
655    #[tokio::test]
656    async fn search_and_process_no_results() {
657        let (discovery, poster, _) = build_loop(Vec::new(), 80.0, true, false);
658        let (results, summary) = discovery.search_and_process("rust", None).await.unwrap();
659        assert_eq!(summary.tweets_found, 0);
660        assert!(results.is_empty());
661        assert_eq!(poster.sent_count(), 0);
662    }
663
664    #[tokio::test]
665    async fn search_and_process_above_threshold() {
666        let tweets = vec![test_tweet("100", "alice"), test_tweet("101", "bob")];
667        let (discovery, poster, storage) = build_loop(tweets, 85.0, true, false);
668
669        let (results, summary) = discovery.search_and_process("rust", None).await.unwrap();
670
671        assert_eq!(summary.tweets_found, 2);
672        assert_eq!(summary.replied, 2);
673        assert_eq!(results.len(), 2);
674        assert_eq!(poster.sent_count(), 2);
675
676        // Both tweets should be stored as discovered
677        let discovered = storage.discovered.lock().expect("lock");
678        assert_eq!(discovered.len(), 2);
679    }
680
681    #[tokio::test]
682    async fn search_and_process_below_threshold() {
683        let tweets = vec![test_tweet("100", "alice")];
684        let (discovery, poster, storage) = build_loop(tweets, 40.0, false, false);
685
686        let (results, summary) = discovery.search_and_process("rust", None).await.unwrap();
687
688        assert_eq!(summary.tweets_found, 1);
689        assert_eq!(summary.skipped, 1);
690        assert_eq!(summary.replied, 0);
691        assert_eq!(results.len(), 1);
692        assert_eq!(poster.sent_count(), 0);
693
694        // Tweet should still be stored as discovered (for analytics)
695        let discovered = storage.discovered.lock().expect("lock");
696        assert_eq!(discovered.len(), 1);
697    }
698
699    #[tokio::test]
700    async fn search_and_process_dry_run() {
701        let tweets = vec![test_tweet("100", "alice")];
702        let (discovery, poster, _) = build_loop(tweets, 85.0, true, true);
703
704        let (_results, summary) = discovery.search_and_process("rust", None).await.unwrap();
705
706        assert_eq!(summary.replied, 1);
707        // Should NOT post in dry-run
708        assert_eq!(poster.sent_count(), 0);
709    }
710
711    #[tokio::test]
712    async fn search_and_process_skips_existing() {
713        let tweets = vec![test_tweet("100", "alice")];
714        let poster = Arc::new(MockPoster::new());
715        let storage = Arc::new(MockStorage::new());
716        // Pre-mark tweet as existing
717        storage
718            .existing_ids
719            .lock()
720            .expect("lock")
721            .push("100".to_string());
722
723        let discovery = DiscoveryLoop::new(
724            Arc::new(MockSearcher { results: tweets }),
725            Arc::new(MockScorer {
726                score: 85.0,
727                meets_threshold: true,
728            }),
729            Arc::new(MockGenerator {
730                reply: "Great!".to_string(),
731            }),
732            Arc::new(MockSafety::new(true)),
733            storage,
734            poster.clone(),
735            vec!["rust".to_string()],
736            70.0,
737            false,
738        );
739
740        let (_results, summary) = discovery.search_and_process("rust", None).await.unwrap();
741        assert_eq!(summary.skipped, 1);
742        assert_eq!(poster.sent_count(), 0);
743    }
744
745    #[tokio::test]
746    async fn search_and_process_respects_limit() {
747        let tweets = vec![
748            test_tweet("100", "alice"),
749            test_tweet("101", "bob"),
750            test_tweet("102", "carol"),
751        ];
752        let (discovery, poster, _) = build_loop(tweets, 85.0, true, false);
753
754        let (results, summary) = discovery.search_and_process("rust", Some(2)).await.unwrap();
755
756        assert_eq!(summary.tweets_found, 3); // found 3, but...
757        assert_eq!(results.len(), 2); // only 2 results returned
758        assert_eq!(poster.sent_count(), 2); // only processed 2
759    }
760
761    #[tokio::test]
762    async fn run_once_searches_all_keywords() {
763        let tweets = vec![test_tweet("100", "alice")];
764        let (discovery, _, _) = build_loop(tweets, 85.0, true, false);
765
766        let (_, summary) = discovery.run_once(None).await.unwrap();
767        // Should search both "rust" and "cli" keywords
768        assert_eq!(summary.tweets_found, 2); // 1 tweet per keyword
769    }
770
771    #[test]
772    fn discovery_summary_default() {
773        let s = DiscoverySummary::default();
774        assert_eq!(s.tweets_found, 0);
775        assert_eq!(s.qualifying, 0);
776        assert_eq!(s.replied, 0);
777        assert_eq!(s.skipped, 0);
778        assert_eq!(s.failed, 0);
779    }
780
781    #[test]
782    fn discovery_result_debug() {
783        let r = DiscoveryResult::Replied {
784            tweet_id: "1".to_string(),
785            author: "alice".to_string(),
786            score: 85.0,
787            reply_text: "Great!".to_string(),
788        };
789        let debug = format!("{r:?}");
790        assert!(debug.contains("Replied"));
791
792        let r = DiscoveryResult::BelowThreshold {
793            tweet_id: "2".to_string(),
794            score: 30.0,
795        };
796        let debug = format!("{r:?}");
797        assert!(debug.contains("BelowThreshold"));
798
799        let r = DiscoveryResult::Skipped {
800            tweet_id: "3".to_string(),
801            reason: "test".to_string(),
802        };
803        assert!(format!("{r:?}").contains("Skipped"));
804
805        let r = DiscoveryResult::Failed {
806            tweet_id: "4".to_string(),
807            error: "boom".to_string(),
808        };
809        assert!(format!("{r:?}").contains("Failed"));
810    }
811
812    #[test]
813    fn truncate_exact_length() {
814        assert_eq!(truncate("hello", 5), "hello");
815    }
816
817    #[test]
818    fn truncate_empty_string() {
819        assert_eq!(truncate("", 10), "");
820    }
821
822    #[tokio::test]
823    async fn run_once_all_keywords_fail_returns_error() {
824        let poster = Arc::new(MockPoster::new());
825        let storage = Arc::new(MockStorage::new());
826        let discovery = DiscoveryLoop::new(
827            Arc::new(FailingSearcher),
828            Arc::new(MockScorer {
829                score: 85.0,
830                meets_threshold: true,
831            }),
832            Arc::new(MockGenerator {
833                reply: "test".to_string(),
834            }),
835            Arc::new(MockSafety::new(true)),
836            storage,
837            poster,
838            vec!["rust".to_string(), "cli".to_string()],
839            70.0,
840            false,
841        );
842
843        let result = discovery.run_once(None).await;
844        assert!(result.is_err());
845    }
846
847    #[tokio::test]
848    async fn search_error_returns_loop_error() {
849        let poster = Arc::new(MockPoster::new());
850        let storage = Arc::new(MockStorage::new());
851        let discovery = DiscoveryLoop::new(
852            Arc::new(FailingSearcher),
853            Arc::new(MockScorer {
854                score: 85.0,
855                meets_threshold: true,
856            }),
857            Arc::new(MockGenerator {
858                reply: "test".to_string(),
859            }),
860            Arc::new(MockSafety::new(true)),
861            storage,
862            poster,
863            vec!["rust".to_string()],
864            70.0,
865            false,
866        );
867
868        let result = discovery.search_and_process("rust", None).await;
869        assert!(result.is_err());
870    }
871
872    // ── Additional coverage tests ────────────────────────────────────
873
874    #[test]
875    fn truncate_long_string() {
876        let result = truncate("hello world, this is a long string", 10);
877        assert_eq!(result, "hello worl...");
878    }
879
880    #[test]
881    fn truncate_one_char() {
882        assert_eq!(truncate("x", 1), "x");
883    }
884
885    #[test]
886    fn truncate_zero_max() {
887        assert_eq!(truncate("hello", 0), "...");
888    }
889
890    #[tokio::test]
891    async fn search_and_process_rate_limited_safety_skips() {
892        // Safety checker says can_reply=false, so tweet should be skipped
893        let tweets = vec![test_tweet("200", "dave")];
894        let poster = Arc::new(MockPoster::new());
895        let storage = Arc::new(MockStorage::new());
896        let discovery = DiscoveryLoop::new(
897            Arc::new(MockSearcher { results: tweets }),
898            Arc::new(MockScorer {
899                score: 90.0,
900                meets_threshold: true,
901            }),
902            Arc::new(MockGenerator {
903                reply: "Great!".to_string(),
904            }),
905            Arc::new(MockSafety::new(false)), // can_reply = false
906            storage,
907            poster.clone(),
908            vec!["rust".to_string()],
909            70.0,
910            false,
911        );
912
913        let (results, summary) = discovery.search_and_process("rust", None).await.unwrap();
914        assert_eq!(summary.tweets_found, 1);
915        assert_eq!(summary.skipped, 1);
916        assert_eq!(summary.replied, 0);
917        assert_eq!(poster.sent_count(), 0);
918        assert!(matches!(results[0], DiscoveryResult::Skipped { .. }));
919    }
920
921    #[tokio::test]
922    async fn run_once_with_limit() {
923        let tweets = vec![
924            test_tweet("300", "alice"),
925            test_tweet("301", "bob"),
926            test_tweet("302", "carol"),
927        ];
928        let (discovery, poster, _) = build_loop(tweets, 85.0, true, false);
929
930        let (_, summary) = discovery.run_once(Some(2)).await.unwrap();
931        // Should stop after processing 2 total across keywords
932        assert!(summary.tweets_found <= 3);
933        assert!(poster.sent_count() <= 2);
934    }
935
936    #[tokio::test]
937    async fn run_once_empty_keywords() {
938        let poster = Arc::new(MockPoster::new());
939        let storage = Arc::new(MockStorage::new());
940        let discovery = DiscoveryLoop::new(
941            Arc::new(MockSearcher {
942                results: Vec::new(),
943            }),
944            Arc::new(MockScorer {
945                score: 85.0,
946                meets_threshold: true,
947            }),
948            Arc::new(MockGenerator {
949                reply: "test".to_string(),
950            }),
951            Arc::new(MockSafety::new(true)),
952            storage,
953            poster,
954            Vec::new(), // no keywords
955            70.0,
956            false,
957        );
958
959        let (results, summary) = discovery.run_once(None).await.unwrap();
960        assert_eq!(summary.tweets_found, 0);
961        assert!(results.is_empty());
962    }
963
964    // ── FailingGenerator ─────────────────────────────────────────────
965
966    struct FailingGenerator;
967
968    #[async_trait::async_trait]
969    impl ReplyGenerator for FailingGenerator {
970        async fn generate_reply(
971            &self,
972            _tweet_text: &str,
973            _author: &str,
974            _mention_product: bool,
975        ) -> Result<String, LoopError> {
976            Err(LoopError::LlmFailure("LLM error".into()))
977        }
978    }
979
980    #[tokio::test]
981    async fn process_tweet_generation_failure_returns_failed() {
982        let tweets = vec![test_tweet("400", "eve")];
983        let poster = Arc::new(MockPoster::new());
984        let storage = Arc::new(MockStorage::new());
985        let discovery = DiscoveryLoop::new(
986            Arc::new(MockSearcher { results: tweets }),
987            Arc::new(MockScorer {
988                score: 90.0,
989                meets_threshold: true,
990            }),
991            Arc::new(FailingGenerator),
992            Arc::new(MockSafety::new(true)),
993            storage,
994            poster.clone(),
995            vec!["rust".to_string()],
996            70.0,
997            false,
998        );
999
1000        let (results, summary) = discovery.search_and_process("rust", None).await.unwrap();
1001        assert_eq!(summary.failed, 1);
1002        assert_eq!(poster.sent_count(), 0);
1003        assert!(matches!(results[0], DiscoveryResult::Failed { .. }));
1004    }
1005
1006    // ── FailingPoster ────────────────────────────────────────────────
1007
1008    struct FailingPoster;
1009
1010    #[async_trait::async_trait]
1011    impl PostSender for FailingPoster {
1012        async fn send_reply(&self, _tweet_id: &str, _content: &str) -> Result<(), LoopError> {
1013            Err(LoopError::NetworkError("API error".into()))
1014        }
1015    }
1016
1017    #[tokio::test]
1018    async fn process_tweet_post_failure_returns_failed() {
1019        let tweets = vec![test_tweet("500", "frank")];
1020        let storage = Arc::new(MockStorage::new());
1021        let discovery = DiscoveryLoop::new(
1022            Arc::new(MockSearcher { results: tweets }),
1023            Arc::new(MockScorer {
1024                score: 90.0,
1025                meets_threshold: true,
1026            }),
1027            Arc::new(MockGenerator {
1028                reply: "Great!".to_string(),
1029            }),
1030            Arc::new(MockSafety::new(true)),
1031            storage,
1032            Arc::new(FailingPoster),
1033            vec!["rust".to_string()],
1034            70.0,
1035            false,
1036        );
1037
1038        let (results, summary) = discovery.search_and_process("rust", None).await.unwrap();
1039        assert_eq!(summary.failed, 1);
1040        assert!(matches!(results[0], DiscoveryResult::Failed { .. }));
1041    }
1042}