1use 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
18pub 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#[derive(Debug)]
33pub enum DiscoveryResult {
34 Replied {
36 tweet_id: String,
37 author: String,
38 score: f32,
39 reply_text: String,
40 },
41 BelowThreshold { tweet_id: String, score: f32 },
43 Skipped { tweet_id: String, reason: String },
45 Failed { tweet_id: String, error: String },
47}
48
49#[derive(Debug, Default)]
51pub struct DiscoverySummary {
52 pub tweets_found: usize,
54 pub qualifying: usize,
56 pub replied: usize,
58 pub skipped: usize,
60 pub failed: usize,
62}
63
64impl DiscoveryLoop {
65 #[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 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 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 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 !any_success {
227 if let Some(err) = last_error {
228 return Err(err);
229 }
230 }
231
232 Ok((all_results, summary))
233 }
234
235 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 async fn process_tweet(&self, tweet: &LoopTweet, keyword: &str) -> DiscoveryResult {
283 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 }
297 }
298
299 let score_result = self.scorer.score(tweet);
301
302 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 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 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 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
427fn 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 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 #[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 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 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 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 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); assert_eq!(results.len(), 2); assert_eq!(poster.sent_count(), 2); }
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 assert_eq!(summary.tweets_found, 2); }
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 #[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 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)), 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 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(), 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 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 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}