Skip to main content

tuitbot_core/automation/
target_loop.rs

1//! Target account monitoring loop.
2//!
3//! Fetches recent tweets from configured target accounts, scores them
4//! with adjusted weights (preferring recency and low reply count), and
5//! generates relationship-based replies. This loop operates independently
6//! from keyword-based discovery to enable genuine engagement with specific
7//! people.
8
9use 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// ============================================================================
19// Port traits specific to target loop
20// ============================================================================
21
22/// Fetches tweets from a specific user by user ID.
23#[async_trait::async_trait]
24pub trait TargetTweetFetcher: Send + Sync {
25    /// Fetch recent tweets from the given user.
26    async fn fetch_user_tweets(&self, user_id: &str) -> Result<Vec<LoopTweet>, LoopError>;
27}
28
29/// Looks up a user by username and optionally follows them.
30#[async_trait::async_trait]
31pub trait TargetUserManager: Send + Sync {
32    /// Look up a user by username. Returns (user_id, username).
33    async fn lookup_user(&self, username: &str) -> Result<(String, String), LoopError>;
34
35    /// Follow a user.
36    async fn follow_user(
37        &self,
38        source_user_id: &str,
39        target_user_id: &str,
40    ) -> Result<(), LoopError>;
41}
42
43/// Storage operations for target account state.
44#[allow(clippy::too_many_arguments)]
45#[async_trait::async_trait]
46pub trait TargetStorage: Send + Sync {
47    /// Upsert a target account record.
48    async fn upsert_target_account(
49        &self,
50        account_id: &str,
51        username: &str,
52    ) -> Result<(), LoopError>;
53
54    /// Get the followed_at timestamp for a target account.
55    async fn get_followed_at(&self, account_id: &str) -> Result<Option<String>, LoopError>;
56
57    /// Record that we followed a target account.
58    async fn record_follow(&self, account_id: &str) -> Result<(), LoopError>;
59
60    /// Check if a target tweet already exists.
61    async fn target_tweet_exists(&self, tweet_id: &str) -> Result<bool, LoopError>;
62
63    /// Store a discovered target tweet.
64    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    /// Mark a target tweet as replied to.
76    async fn mark_target_tweet_replied(&self, tweet_id: &str) -> Result<(), LoopError>;
77
78    /// Record a reply to a target account (increments counter).
79    async fn record_target_reply(&self, account_id: &str) -> Result<(), LoopError>;
80
81    /// Get count of target replies sent today.
82    async fn count_target_replies_today(&self) -> Result<i64, LoopError>;
83
84    /// Log an action.
85    async fn log_action(
86        &self,
87        action_type: &str,
88        status: &str,
89        message: &str,
90    ) -> Result<(), LoopError>;
91}
92
93// ============================================================================
94// Target loop config
95// ============================================================================
96
97/// Configuration for the target monitoring loop.
98#[derive(Debug, Clone)]
99pub struct TargetLoopConfig {
100    /// Target account usernames (without @).
101    pub accounts: Vec<String>,
102    /// Maximum target replies per day.
103    pub max_target_replies_per_day: u32,
104    /// Whether to auto-follow target accounts.
105    pub auto_follow: bool,
106    /// Days to wait after following before engaging.
107    pub follow_warmup_days: u32,
108    /// Our own user ID (to pass for follow_user).
109    pub own_user_id: String,
110    /// Whether this is a dry run.
111    pub dry_run: bool,
112}
113
114// ============================================================================
115// Target loop result
116// ============================================================================
117
118/// Result of processing a single target tweet.
119#[derive(Debug)]
120pub enum TargetResult {
121    /// Reply was sent (or would be in dry-run).
122    Replied {
123        tweet_id: String,
124        account: String,
125        reply_text: String,
126    },
127    /// Tweet was skipped.
128    Skipped { tweet_id: String, reason: String },
129    /// Processing failed.
130    Failed { tweet_id: String, error: String },
131}
132
133// ============================================================================
134// Target loop
135// ============================================================================
136
137/// Monitors target accounts and generates relationship-based replies.
138pub 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    /// Create a new target monitoring loop.
150    #[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    /// Run the continuous target monitoring loop until cancellation.
172    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    /// Run a single iteration across all target accounts.
255    pub async fn run_iteration(&self) -> Result<Vec<TargetResult>, LoopError> {
256        let mut all_results = Vec::new();
257
258        // Check daily limit
259        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                    // AuthExpired is global — stop immediately instead of
288                    // failing N times with the same 401.
289                    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    /// Process a single target account: resolve, optionally follow, fetch tweets, reply.
310    async fn process_account(
311        &self,
312        username: &str,
313        max_replies: usize,
314    ) -> Result<Vec<TargetResult>, LoopError> {
315        // Look up user
316        let (user_id, resolved_username) = self.user_mgr.lookup_user(username).await?;
317
318        // Upsert target account record
319        self.storage
320            .upsert_target_account(&user_id, &resolved_username)
321            .await?;
322
323        // Handle auto-follow
324        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                            // Don't engage yet — warmup period starts now
347                            return Ok(Vec::new());
348                        }
349                        Err(e) => {
350                            // AuthExpired should propagate — no point fetching tweets either.
351                            if matches!(e, LoopError::AuthExpired) {
352                                return Err(e);
353                            }
354
355                            // Follow failed (e.g. 403 on Basic tier). Log warning but
356                            // continue to engagement — following is best-effort.
357                            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                            // Record as "followed" to avoid retrying every iteration
373                            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                    // Don't engage yet — warmup period starts now
387                    return Ok(Vec::new());
388                }
389            }
390
391            // Check warmup period (skip if follow was recorded due to failure)
392            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        // Fetch recent tweets
407        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                // Only reply to one tweet per account per iteration
425                break;
426            }
427            results.push(result);
428        }
429
430        Ok(results)
431    }
432
433    /// Process a single target tweet: dedup, safety check, generate reply, post.
434    async fn process_target_tweet(
435        &self,
436        tweet: &LoopTweet,
437        account_id: &str,
438        username: &str,
439    ) -> TargetResult {
440        // Check if already seen
441        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        // Store the discovered tweet
455        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        // Safety checks
469        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        // Generate reply (no product mention for target accounts — be genuine)
484        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            // Mark tweet as replied and update account stats
533            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
554/// Check if the follow warmup period has elapsed.
555fn warmup_elapsed(followed_at: &str, warmup_days: u32) -> bool {
556    // Parse the SQLite datetime format ("YYYY-MM-DD HH:MM:SS")
557    let followed = match chrono::NaiveDateTime::parse_from_str(followed_at, "%Y-%m-%d %H:%M:%S") {
558        Ok(dt) => dt,
559        Err(_) => return true, // If we can't parse, assume warmup is done
560    };
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
567/// Truncate a string for display.
568fn 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    // --- Mock implementations ---
583
584    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)>, // (username, user_id, resolved_username)
597    }
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    // --- Tests ---
827
828    #[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        // Should follow but NOT reply (warmup starts)
905        assert!(results.is_empty());
906        assert_eq!(poster.sent_count(), 0);
907        // Should have recorded the follow
908        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        // Followed yesterday — warmup is 3 days
915        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        // Followed 5 days ago — warmup is 3 days
932        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    // --- AuthExpired-aware mock ---
974
975    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    /// A user manager where the first lookup fails and the second succeeds.
1010    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        // Only one lookup should have been attempted — the loop exits early.
1063        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        // First account fails with Other, second succeeds — both should be attempted.
1093        assert_eq!(user_mgr.call_count.load(Ordering::SeqCst), 2);
1094        // Second account produces a reply.
1095        assert_eq!(results.len(), 1);
1096        assert!(matches!(results[0], TargetResult::Replied { .. }));
1097        assert_eq!(poster.sent_count(), 1);
1098    }
1099}