1use super::loop_helpers::{
10 ConsecutiveErrorTracker, LoopError, LoopTweet, PostSender, ReplyGenerator, SafetyChecker,
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#[async_trait::async_trait]
24pub trait TargetTweetFetcher: Send + Sync {
25 async fn fetch_user_tweets(&self, user_id: &str) -> Result<Vec<LoopTweet>, LoopError>;
27}
28
29#[async_trait::async_trait]
31pub trait TargetUserManager: Send + Sync {
32 async fn lookup_user(&self, username: &str) -> Result<(String, String), LoopError>;
34
35 async fn follow_user(
37 &self,
38 source_user_id: &str,
39 target_user_id: &str,
40 ) -> Result<(), LoopError>;
41}
42
43#[allow(clippy::too_many_arguments)]
45#[async_trait::async_trait]
46pub trait TargetStorage: Send + Sync {
47 async fn upsert_target_account(
49 &self,
50 account_id: &str,
51 username: &str,
52 ) -> Result<(), LoopError>;
53
54 async fn get_followed_at(&self, account_id: &str) -> Result<Option<String>, LoopError>;
56
57 async fn record_follow(&self, account_id: &str) -> Result<(), LoopError>;
59
60 async fn target_tweet_exists(&self, tweet_id: &str) -> Result<bool, LoopError>;
62
63 async fn store_target_tweet(
65 &self,
66 tweet_id: &str,
67 account_id: &str,
68 content: &str,
69 created_at: &str,
70 reply_count: i64,
71 like_count: i64,
72 relevance_score: f64,
73 ) -> Result<(), LoopError>;
74
75 async fn mark_target_tweet_replied(&self, tweet_id: &str) -> Result<(), LoopError>;
77
78 async fn record_target_reply(&self, account_id: &str) -> Result<(), LoopError>;
80
81 async fn count_target_replies_today(&self) -> Result<i64, LoopError>;
83
84 async fn log_action(
86 &self,
87 action_type: &str,
88 status: &str,
89 message: &str,
90 ) -> Result<(), LoopError>;
91}
92
93#[derive(Debug, Clone)]
99pub struct TargetLoopConfig {
100 pub accounts: Vec<String>,
102 pub max_target_replies_per_day: u32,
104 pub auto_follow: bool,
106 pub follow_warmup_days: u32,
108 pub own_user_id: String,
110 pub dry_run: bool,
112}
113
114#[derive(Debug)]
120pub enum TargetResult {
121 Replied {
123 tweet_id: String,
124 account: String,
125 reply_text: String,
126 },
127 Skipped { tweet_id: String, reason: String },
129 Failed { tweet_id: String, error: String },
131}
132
133pub struct TargetLoop {
139 fetcher: Arc<dyn TargetTweetFetcher>,
140 user_mgr: Arc<dyn TargetUserManager>,
141 generator: Arc<dyn ReplyGenerator>,
142 safety: Arc<dyn SafetyChecker>,
143 storage: Arc<dyn TargetStorage>,
144 poster: Arc<dyn PostSender>,
145 config: TargetLoopConfig,
146}
147
148impl TargetLoop {
149 #[allow(clippy::too_many_arguments)]
151 pub fn new(
152 fetcher: Arc<dyn TargetTweetFetcher>,
153 user_mgr: Arc<dyn TargetUserManager>,
154 generator: Arc<dyn ReplyGenerator>,
155 safety: Arc<dyn SafetyChecker>,
156 storage: Arc<dyn TargetStorage>,
157 poster: Arc<dyn PostSender>,
158 config: TargetLoopConfig,
159 ) -> Self {
160 Self {
161 fetcher,
162 user_mgr,
163 generator,
164 safety,
165 storage,
166 poster,
167 config,
168 }
169 }
170
171 pub async fn run(
173 &self,
174 cancel: CancellationToken,
175 scheduler: LoopScheduler,
176 schedule: Option<Arc<ActiveSchedule>>,
177 ) {
178 tracing::info!(
179 dry_run = self.config.dry_run,
180 accounts = self.config.accounts.len(),
181 max_replies = self.config.max_target_replies_per_day,
182 "Target monitoring loop started"
183 );
184
185 if self.config.accounts.is_empty() {
186 tracing::info!("No target accounts configured, target loop has nothing to do");
187 cancel.cancelled().await;
188 return;
189 }
190
191 let mut error_tracker = ConsecutiveErrorTracker::new(10, Duration::from_secs(300));
192
193 loop {
194 if cancel.is_cancelled() {
195 break;
196 }
197
198 if !schedule_gate(&schedule, &cancel).await {
199 break;
200 }
201
202 match self.run_iteration().await {
203 Ok(results) => {
204 error_tracker.record_success();
205 let replied = results
206 .iter()
207 .filter(|r| matches!(r, TargetResult::Replied { .. }))
208 .count();
209 let skipped = results
210 .iter()
211 .filter(|r| matches!(r, TargetResult::Skipped { .. }))
212 .count();
213 if !results.is_empty() {
214 tracing::info!(
215 total = results.len(),
216 replied = replied,
217 skipped = skipped,
218 "Target iteration complete"
219 );
220 }
221 }
222 Err(e) => {
223 let should_pause = error_tracker.record_error();
224 tracing::warn!(
225 error = %e,
226 consecutive_errors = error_tracker.count(),
227 "Target iteration failed"
228 );
229
230 if should_pause {
231 tracing::warn!(
232 pause_secs = error_tracker.pause_duration().as_secs(),
233 "Pausing target loop due to consecutive errors"
234 );
235 tokio::select! {
236 _ = cancel.cancelled() => break,
237 _ = tokio::time::sleep(error_tracker.pause_duration()) => {},
238 }
239 error_tracker.reset();
240 continue;
241 }
242 }
243 }
244
245 tokio::select! {
246 _ = cancel.cancelled() => break,
247 _ = scheduler.tick() => {},
248 }
249 }
250
251 tracing::info!("Target monitoring loop stopped");
252 }
253
254 pub async fn run_iteration(&self) -> Result<Vec<TargetResult>, LoopError> {
256 let mut all_results = Vec::new();
257
258 let replies_today = self.storage.count_target_replies_today().await?;
260 if replies_today >= self.config.max_target_replies_per_day as i64 {
261 tracing::debug!(
262 replies_today = replies_today,
263 limit = self.config.max_target_replies_per_day,
264 "Target reply daily limit reached"
265 );
266 return Ok(all_results);
267 }
268
269 let mut remaining_replies =
270 (self.config.max_target_replies_per_day as i64 - replies_today) as usize;
271
272 for username in &self.config.accounts {
273 if remaining_replies == 0 {
274 break;
275 }
276
277 match self.process_account(username, remaining_replies).await {
278 Ok(results) => {
279 let replied_count = results
280 .iter()
281 .filter(|r| matches!(r, TargetResult::Replied { .. }))
282 .count();
283 remaining_replies = remaining_replies.saturating_sub(replied_count);
284 all_results.extend(results);
285 }
286 Err(e) => {
287 if matches!(e, LoopError::AuthExpired) {
290 tracing::error!(
291 username = %username,
292 "X API authentication expired, re-authenticate with `tuitbot init`"
293 );
294 return Err(e);
295 }
296
297 tracing::warn!(
298 username = %username,
299 error = %e,
300 "Failed to process target account"
301 );
302 }
303 }
304 }
305
306 Ok(all_results)
307 }
308
309 async fn process_account(
311 &self,
312 username: &str,
313 max_replies: usize,
314 ) -> Result<Vec<TargetResult>, LoopError> {
315 let (user_id, resolved_username) = self.user_mgr.lookup_user(username).await?;
317
318 self.storage
320 .upsert_target_account(&user_id, &resolved_username)
321 .await?;
322
323 if self.config.auto_follow {
325 let followed_at = self.storage.get_followed_at(&user_id).await?;
326 if followed_at.is_none() {
327 tracing::info!(username = %resolved_username, "Auto-following target account");
328 if !self.config.dry_run {
329 match self
330 .user_mgr
331 .follow_user(&self.config.own_user_id, &user_id)
332 .await
333 {
334 Ok(()) => {
335 self.storage.record_follow(&user_id).await?;
336
337 let _ = self
338 .storage
339 .log_action(
340 "target_follow",
341 "success",
342 &format!("Followed @{resolved_username}"),
343 )
344 .await;
345
346 return Ok(Vec::new());
348 }
349 Err(e) => {
350 if matches!(e, LoopError::AuthExpired) {
352 return Err(e);
353 }
354
355 tracing::warn!(
358 username = %resolved_username,
359 error = %e,
360 "Auto-follow failed (API tier may not support follows), skipping follow"
361 );
362
363 let _ = self
364 .storage
365 .log_action(
366 "target_follow",
367 "skipped",
368 &format!("Follow @{resolved_username} failed: {e}"),
369 )
370 .await;
371
372 let _ = self.storage.record_follow(&user_id).await;
374 }
375 }
376 } else {
377 let _ = self
378 .storage
379 .log_action(
380 "target_follow",
381 "dry_run",
382 &format!("Followed @{resolved_username}"),
383 )
384 .await;
385
386 return Ok(Vec::new());
388 }
389 }
390
391 if self.config.follow_warmup_days > 0 {
393 if let Some(ref followed_str) = self.storage.get_followed_at(&user_id).await? {
394 if !warmup_elapsed(followed_str, self.config.follow_warmup_days) {
395 tracing::debug!(
396 username = %resolved_username,
397 warmup_days = self.config.follow_warmup_days,
398 "Still in follow warmup period"
399 );
400 return Ok(Vec::new());
401 }
402 }
403 }
404 }
405
406 let tweets = self.fetcher.fetch_user_tweets(&user_id).await?;
408 tracing::info!(
409 username = %resolved_username,
410 count = tweets.len(),
411 "Monitoring @{}, found {} new tweets",
412 resolved_username,
413 tweets.len(),
414 );
415
416 let mut results = Vec::new();
417
418 for tweet in tweets.iter().take(max_replies) {
419 let result = self
420 .process_target_tweet(tweet, &user_id, &resolved_username)
421 .await;
422 if matches!(result, TargetResult::Replied { .. }) {
423 results.push(result);
424 break;
426 }
427 results.push(result);
428 }
429
430 Ok(results)
431 }
432
433 async fn process_target_tweet(
435 &self,
436 tweet: &LoopTweet,
437 account_id: &str,
438 username: &str,
439 ) -> TargetResult {
440 match self.storage.target_tweet_exists(&tweet.id).await {
442 Ok(true) => {
443 return TargetResult::Skipped {
444 tweet_id: tweet.id.clone(),
445 reason: "already discovered".to_string(),
446 };
447 }
448 Ok(false) => {}
449 Err(e) => {
450 tracing::warn!(tweet_id = %tweet.id, error = %e, "Failed to check target tweet");
451 }
452 }
453
454 let _ = self
456 .storage
457 .store_target_tweet(
458 &tweet.id,
459 account_id,
460 &tweet.text,
461 &tweet.created_at,
462 tweet.replies as i64,
463 tweet.likes as i64,
464 0.0,
465 )
466 .await;
467
468 if self.safety.has_replied_to(&tweet.id).await {
470 return TargetResult::Skipped {
471 tweet_id: tweet.id.clone(),
472 reason: "already replied".to_string(),
473 };
474 }
475
476 if !self.safety.can_reply().await {
477 return TargetResult::Skipped {
478 tweet_id: tweet.id.clone(),
479 reason: "rate limited".to_string(),
480 };
481 }
482
483 let reply_text = match self
485 .generator
486 .generate_reply(&tweet.text, username, false)
487 .await
488 {
489 Ok(text) => text,
490 Err(e) => {
491 return TargetResult::Failed {
492 tweet_id: tweet.id.clone(),
493 error: e.to_string(),
494 };
495 }
496 };
497
498 tracing::info!(
499 username = %username,
500 "Replied to target @{}",
501 username,
502 );
503
504 if self.config.dry_run {
505 tracing::info!(
506 "DRY RUN: Target @{} tweet {} -- Would reply: \"{}\"",
507 username,
508 tweet.id,
509 reply_text
510 );
511
512 let _ = self
513 .storage
514 .log_action(
515 "target_reply",
516 "dry_run",
517 &format!("Reply to @{username}: {}", truncate(&reply_text, 50)),
518 )
519 .await;
520 } else {
521 if let Err(e) = self.poster.send_reply(&tweet.id, &reply_text).await {
522 return TargetResult::Failed {
523 tweet_id: tweet.id.clone(),
524 error: e.to_string(),
525 };
526 }
527
528 if let Err(e) = self.safety.record_reply(&tweet.id, &reply_text).await {
529 tracing::warn!(tweet_id = %tweet.id, error = %e, "Failed to record reply");
530 }
531
532 let _ = self.storage.mark_target_tweet_replied(&tweet.id).await;
534 let _ = self.storage.record_target_reply(account_id).await;
535
536 let _ = self
537 .storage
538 .log_action(
539 "target_reply",
540 "success",
541 &format!("Replied to @{username}: {}", truncate(&reply_text, 50)),
542 )
543 .await;
544 }
545
546 TargetResult::Replied {
547 tweet_id: tweet.id.clone(),
548 account: username.to_string(),
549 reply_text,
550 }
551 }
552}
553
554fn warmup_elapsed(followed_at: &str, warmup_days: u32) -> bool {
556 let followed = match chrono::NaiveDateTime::parse_from_str(followed_at, "%Y-%m-%d %H:%M:%S") {
558 Ok(dt) => dt,
559 Err(_) => return true, };
561
562 let now = chrono::Utc::now().naive_utc();
563 let elapsed = now.signed_duration_since(followed);
564 elapsed.num_days() >= warmup_days as i64
565}
566
567fn truncate(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 std::sync::atomic::{AtomicU32, Ordering};
580 use std::sync::Mutex;
581
582 struct MockFetcher {
585 tweets: Vec<LoopTweet>,
586 }
587
588 #[async_trait::async_trait]
589 impl TargetTweetFetcher for MockFetcher {
590 async fn fetch_user_tweets(&self, _user_id: &str) -> Result<Vec<LoopTweet>, LoopError> {
591 Ok(self.tweets.clone())
592 }
593 }
594
595 struct MockUserManager {
596 users: Vec<(String, String, String)>, }
598
599 #[async_trait::async_trait]
600 impl TargetUserManager for MockUserManager {
601 async fn lookup_user(&self, username: &str) -> Result<(String, String), LoopError> {
602 for (uname, uid, resolved) in &self.users {
603 if uname == username {
604 return Ok((uid.clone(), resolved.clone()));
605 }
606 }
607 Err(LoopError::Other(format!("user not found: {username}")))
608 }
609
610 async fn follow_user(
611 &self,
612 _source_user_id: &str,
613 _target_user_id: &str,
614 ) -> Result<(), LoopError> {
615 Ok(())
616 }
617 }
618
619 struct MockGenerator {
620 reply: String,
621 }
622
623 #[async_trait::async_trait]
624 impl ReplyGenerator for MockGenerator {
625 async fn generate_reply(
626 &self,
627 _tweet_text: &str,
628 _author: &str,
629 _mention_product: bool,
630 ) -> Result<String, LoopError> {
631 Ok(self.reply.clone())
632 }
633 }
634
635 struct MockSafety {
636 can_reply: bool,
637 replied_ids: Mutex<Vec<String>>,
638 }
639
640 impl MockSafety {
641 fn new(can_reply: bool) -> Self {
642 Self {
643 can_reply,
644 replied_ids: Mutex::new(Vec::new()),
645 }
646 }
647 }
648
649 #[async_trait::async_trait]
650 impl SafetyChecker for MockSafety {
651 async fn can_reply(&self) -> bool {
652 self.can_reply
653 }
654 async fn has_replied_to(&self, tweet_id: &str) -> bool {
655 self.replied_ids
656 .lock()
657 .expect("lock")
658 .contains(&tweet_id.to_string())
659 }
660 async fn record_reply(&self, tweet_id: &str, _content: &str) -> Result<(), LoopError> {
661 self.replied_ids
662 .lock()
663 .expect("lock")
664 .push(tweet_id.to_string());
665 Ok(())
666 }
667 }
668
669 struct MockTargetStorage {
670 followed_at: Mutex<Option<String>>,
671 existing_tweets: Mutex<Vec<String>>,
672 replies_today: Mutex<i64>,
673 }
674
675 impl MockTargetStorage {
676 fn new() -> Self {
677 Self {
678 followed_at: Mutex::new(None),
679 existing_tweets: Mutex::new(Vec::new()),
680 replies_today: Mutex::new(0),
681 }
682 }
683
684 fn with_followed_at(followed_at: &str) -> Self {
685 Self {
686 followed_at: Mutex::new(Some(followed_at.to_string())),
687 existing_tweets: Mutex::new(Vec::new()),
688 replies_today: Mutex::new(0),
689 }
690 }
691 }
692
693 #[async_trait::async_trait]
694 impl TargetStorage for MockTargetStorage {
695 async fn upsert_target_account(
696 &self,
697 _account_id: &str,
698 _username: &str,
699 ) -> Result<(), LoopError> {
700 Ok(())
701 }
702 async fn get_followed_at(&self, _account_id: &str) -> Result<Option<String>, LoopError> {
703 Ok(self.followed_at.lock().expect("lock").clone())
704 }
705 async fn record_follow(&self, _account_id: &str) -> Result<(), LoopError> {
706 *self.followed_at.lock().expect("lock") = Some("2026-01-01 00:00:00".to_string());
707 Ok(())
708 }
709 async fn target_tweet_exists(&self, tweet_id: &str) -> Result<bool, LoopError> {
710 Ok(self
711 .existing_tweets
712 .lock()
713 .expect("lock")
714 .contains(&tweet_id.to_string()))
715 }
716 async fn store_target_tweet(
717 &self,
718 _tweet_id: &str,
719 _account_id: &str,
720 _content: &str,
721 _created_at: &str,
722 _reply_count: i64,
723 _like_count: i64,
724 _relevance_score: f64,
725 ) -> Result<(), LoopError> {
726 Ok(())
727 }
728 async fn mark_target_tweet_replied(&self, _tweet_id: &str) -> Result<(), LoopError> {
729 Ok(())
730 }
731 async fn record_target_reply(&self, _account_id: &str) -> Result<(), LoopError> {
732 *self.replies_today.lock().expect("lock") += 1;
733 Ok(())
734 }
735 async fn count_target_replies_today(&self) -> Result<i64, LoopError> {
736 Ok(*self.replies_today.lock().expect("lock"))
737 }
738 async fn log_action(
739 &self,
740 _action_type: &str,
741 _status: &str,
742 _message: &str,
743 ) -> Result<(), LoopError> {
744 Ok(())
745 }
746 }
747
748 struct MockPoster {
749 sent: Mutex<Vec<(String, String)>>,
750 }
751
752 impl MockPoster {
753 fn new() -> Self {
754 Self {
755 sent: Mutex::new(Vec::new()),
756 }
757 }
758 fn sent_count(&self) -> usize {
759 self.sent.lock().expect("lock").len()
760 }
761 }
762
763 #[async_trait::async_trait]
764 impl PostSender for MockPoster {
765 async fn send_reply(&self, tweet_id: &str, content: &str) -> Result<(), LoopError> {
766 self.sent
767 .lock()
768 .expect("lock")
769 .push((tweet_id.to_string(), content.to_string()));
770 Ok(())
771 }
772 }
773
774 fn test_tweet(id: &str, author: &str) -> LoopTweet {
775 LoopTweet {
776 id: id.to_string(),
777 text: format!("Interesting thoughts on tech from @{author}"),
778 author_id: format!("uid_{author}"),
779 author_username: author.to_string(),
780 author_followers: 5000,
781 created_at: "2026-01-01T00:00:00Z".to_string(),
782 likes: 10,
783 retweets: 2,
784 replies: 1,
785 }
786 }
787
788 fn default_config() -> TargetLoopConfig {
789 TargetLoopConfig {
790 accounts: vec!["alice".to_string()],
791 max_target_replies_per_day: 3,
792 auto_follow: false,
793 follow_warmup_days: 3,
794 own_user_id: "own_123".to_string(),
795 dry_run: false,
796 }
797 }
798
799 fn build_loop(
800 tweets: Vec<LoopTweet>,
801 config: TargetLoopConfig,
802 storage: Arc<MockTargetStorage>,
803 ) -> (TargetLoop, Arc<MockPoster>) {
804 let poster = Arc::new(MockPoster::new());
805 let user_mgr = Arc::new(MockUserManager {
806 users: vec![(
807 "alice".to_string(),
808 "uid_alice".to_string(),
809 "alice".to_string(),
810 )],
811 });
812 let target_loop = TargetLoop::new(
813 Arc::new(MockFetcher { tweets }),
814 user_mgr,
815 Arc::new(MockGenerator {
816 reply: "Great point!".to_string(),
817 }),
818 Arc::new(MockSafety::new(true)),
819 storage,
820 poster.clone(),
821 config,
822 );
823 (target_loop, poster)
824 }
825
826 #[tokio::test]
829 async fn empty_accounts_does_nothing() {
830 let storage = Arc::new(MockTargetStorage::new());
831 let mut config = default_config();
832 config.accounts = Vec::new();
833 let (target_loop, poster) = build_loop(Vec::new(), config, storage);
834
835 let results = target_loop.run_iteration().await.expect("iteration");
836 assert!(results.is_empty());
837 assert_eq!(poster.sent_count(), 0);
838 }
839
840 #[tokio::test]
841 async fn replies_to_target_tweet() {
842 let tweets = vec![test_tweet("tw1", "alice")];
843 let storage = Arc::new(MockTargetStorage::new());
844 let (target_loop, poster) = build_loop(tweets, default_config(), storage);
845
846 let results = target_loop.run_iteration().await.expect("iteration");
847 assert_eq!(results.len(), 1);
848 assert!(matches!(results[0], TargetResult::Replied { .. }));
849 assert_eq!(poster.sent_count(), 1);
850 }
851
852 #[tokio::test]
853 async fn skips_existing_target_tweet() {
854 let tweets = vec![test_tweet("tw1", "alice")];
855 let storage = Arc::new(MockTargetStorage::new());
856 storage
857 .existing_tweets
858 .lock()
859 .expect("lock")
860 .push("tw1".to_string());
861 let (target_loop, poster) = build_loop(tweets, default_config(), storage);
862
863 let results = target_loop.run_iteration().await.expect("iteration");
864 assert_eq!(results.len(), 1);
865 assert!(matches!(results[0], TargetResult::Skipped { .. }));
866 assert_eq!(poster.sent_count(), 0);
867 }
868
869 #[tokio::test]
870 async fn respects_daily_limit() {
871 let tweets = vec![test_tweet("tw1", "alice")];
872 let storage = Arc::new(MockTargetStorage::new());
873 *storage.replies_today.lock().expect("lock") = 3;
874 let (target_loop, poster) = build_loop(tweets, default_config(), storage);
875
876 let results = target_loop.run_iteration().await.expect("iteration");
877 assert!(results.is_empty());
878 assert_eq!(poster.sent_count(), 0);
879 }
880
881 #[tokio::test]
882 async fn dry_run_does_not_post() {
883 let tweets = vec![test_tweet("tw1", "alice")];
884 let storage = Arc::new(MockTargetStorage::new());
885 let mut config = default_config();
886 config.dry_run = true;
887 let (target_loop, poster) = build_loop(tweets, config, storage);
888
889 let results = target_loop.run_iteration().await.expect("iteration");
890 assert_eq!(results.len(), 1);
891 assert!(matches!(results[0], TargetResult::Replied { .. }));
892 assert_eq!(poster.sent_count(), 0);
893 }
894
895 #[tokio::test]
896 async fn auto_follow_follows_and_skips_engagement() {
897 let tweets = vec![test_tweet("tw1", "alice")];
898 let storage = Arc::new(MockTargetStorage::new());
899 let mut config = default_config();
900 config.auto_follow = true;
901 let (target_loop, poster) = build_loop(tweets, config, storage.clone());
902
903 let results = target_loop.run_iteration().await.expect("iteration");
904 assert!(results.is_empty());
906 assert_eq!(poster.sent_count(), 0);
907 assert!(storage.followed_at.lock().expect("lock").is_some());
909 }
910
911 #[tokio::test]
912 async fn auto_follow_warmup_blocks_engagement() {
913 let tweets = vec![test_tweet("tw1", "alice")];
914 let now = chrono::Utc::now().naive_utc();
916 let yesterday = now - chrono::Duration::days(1);
917 let followed_str = yesterday.format("%Y-%m-%d %H:%M:%S").to_string();
918 let storage = Arc::new(MockTargetStorage::with_followed_at(&followed_str));
919 let mut config = default_config();
920 config.auto_follow = true;
921 let (target_loop, poster) = build_loop(tweets, config, storage);
922
923 let results = target_loop.run_iteration().await.expect("iteration");
924 assert!(results.is_empty());
925 assert_eq!(poster.sent_count(), 0);
926 }
927
928 #[tokio::test]
929 async fn auto_follow_warmup_elapsed_allows_engagement() {
930 let tweets = vec![test_tweet("tw1", "alice")];
931 let now = chrono::Utc::now().naive_utc();
933 let five_days_ago = now - chrono::Duration::days(5);
934 let followed_str = five_days_ago.format("%Y-%m-%d %H:%M:%S").to_string();
935 let storage = Arc::new(MockTargetStorage::with_followed_at(&followed_str));
936 let mut config = default_config();
937 config.auto_follow = true;
938 let (target_loop, poster) = build_loop(tweets, config, storage);
939
940 let results = target_loop.run_iteration().await.expect("iteration");
941 assert_eq!(results.len(), 1);
942 assert!(matches!(results[0], TargetResult::Replied { .. }));
943 assert_eq!(poster.sent_count(), 1);
944 }
945
946 #[test]
947 fn warmup_elapsed_parses_correctly() {
948 assert!(warmup_elapsed("2020-01-01 00:00:00", 3));
949 assert!(!warmup_elapsed(
950 &chrono::Utc::now()
951 .naive_utc()
952 .format("%Y-%m-%d %H:%M:%S")
953 .to_string(),
954 3
955 ));
956 }
957
958 #[test]
959 fn warmup_elapsed_invalid_date_returns_true() {
960 assert!(warmup_elapsed("not-a-date", 3));
961 }
962
963 #[test]
964 fn truncate_short_string() {
965 assert_eq!(truncate("hello", 10), "hello");
966 }
967
968 #[test]
969 fn truncate_long_string() {
970 assert_eq!(truncate("hello world", 5), "hello...");
971 }
972
973 struct MockAuthExpiredUserManager {
976 lookup_count: AtomicU32,
977 error: LoopError,
978 }
979
980 impl MockAuthExpiredUserManager {
981 fn auth_expired() -> Self {
982 Self {
983 lookup_count: AtomicU32::new(0),
984 error: LoopError::AuthExpired,
985 }
986 }
987 }
988
989 #[async_trait::async_trait]
990 impl TargetUserManager for MockAuthExpiredUserManager {
991 async fn lookup_user(&self, _username: &str) -> Result<(String, String), LoopError> {
992 self.lookup_count.fetch_add(1, Ordering::SeqCst);
993 Err(match &self.error {
994 LoopError::AuthExpired => LoopError::AuthExpired,
995 LoopError::Other(msg) => LoopError::Other(msg.clone()),
996 _ => unreachable!(),
997 })
998 }
999
1000 async fn follow_user(
1001 &self,
1002 _source_user_id: &str,
1003 _target_user_id: &str,
1004 ) -> Result<(), LoopError> {
1005 Ok(())
1006 }
1007 }
1008
1009 struct MockPartialFailUserManager {
1011 call_count: AtomicU32,
1012 }
1013
1014 #[async_trait::async_trait]
1015 impl TargetUserManager for MockPartialFailUserManager {
1016 async fn lookup_user(&self, username: &str) -> Result<(String, String), LoopError> {
1017 let n = self.call_count.fetch_add(1, Ordering::SeqCst);
1018 if n == 0 {
1019 Err(LoopError::Other("transient failure".to_string()))
1020 } else {
1021 Ok((format!("uid_{username}"), username.to_string()))
1022 }
1023 }
1024
1025 async fn follow_user(
1026 &self,
1027 _source_user_id: &str,
1028 _target_user_id: &str,
1029 ) -> Result<(), LoopError> {
1030 Ok(())
1031 }
1032 }
1033
1034 #[tokio::test]
1035 async fn auth_expired_stops_iteration() {
1036 let user_mgr = Arc::new(MockAuthExpiredUserManager::auth_expired());
1037 let storage = Arc::new(MockTargetStorage::new());
1038 let poster = Arc::new(MockPoster::new());
1039
1040 let mut config = default_config();
1041 config.accounts = vec![
1042 "alice".to_string(),
1043 "bob".to_string(),
1044 "charlie".to_string(),
1045 ];
1046
1047 let target_loop = TargetLoop::new(
1048 Arc::new(MockFetcher { tweets: vec![] }),
1049 user_mgr.clone(),
1050 Arc::new(MockGenerator {
1051 reply: "Great!".to_string(),
1052 }),
1053 Arc::new(MockSafety::new(true)),
1054 storage,
1055 poster,
1056 config,
1057 );
1058
1059 let result = target_loop.run_iteration().await;
1060 assert!(result.is_err());
1061 assert!(matches!(result.unwrap_err(), LoopError::AuthExpired));
1062 assert_eq!(user_mgr.lookup_count.load(Ordering::SeqCst), 1);
1064 }
1065
1066 #[tokio::test]
1067 async fn non_auth_error_continues_iteration() {
1068 let user_mgr = Arc::new(MockPartialFailUserManager {
1069 call_count: AtomicU32::new(0),
1070 });
1071 let storage = Arc::new(MockTargetStorage::new());
1072 let poster = Arc::new(MockPoster::new());
1073
1074 let mut config = default_config();
1075 config.accounts = vec!["alice".to_string(), "bob".to_string()];
1076
1077 let target_loop = TargetLoop::new(
1078 Arc::new(MockFetcher {
1079 tweets: vec![test_tweet("tw1", "bob")],
1080 }),
1081 user_mgr.clone(),
1082 Arc::new(MockGenerator {
1083 reply: "Nice!".to_string(),
1084 }),
1085 Arc::new(MockSafety::new(true)),
1086 storage,
1087 poster.clone(),
1088 config,
1089 );
1090
1091 let results = target_loop.run_iteration().await.expect("should succeed");
1092 assert_eq!(user_mgr.call_count.load(Ordering::SeqCst), 2);
1094 assert_eq!(results.len(), 1);
1096 assert!(matches!(results[0], TargetResult::Replied { .. }));
1097 assert_eq!(poster.sent_count(), 1);
1098 }
1099}