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.
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
36/// Storage operations for target account state.
37#[allow(clippy::too_many_arguments)]
38#[async_trait::async_trait]
39pub trait TargetStorage: Send + Sync {
40    /// Upsert a target account record.
41    async fn upsert_target_account(
42        &self,
43        account_id: &str,
44        username: &str,
45    ) -> Result<(), LoopError>;
46
47    /// Check if a target tweet already exists.
48    async fn target_tweet_exists(&self, tweet_id: &str) -> Result<bool, LoopError>;
49
50    /// Store a discovered target tweet.
51    async fn store_target_tweet(
52        &self,
53        tweet_id: &str,
54        account_id: &str,
55        content: &str,
56        created_at: &str,
57        reply_count: i64,
58        like_count: i64,
59        relevance_score: f64,
60    ) -> Result<(), LoopError>;
61
62    /// Mark a target tweet as replied to.
63    async fn mark_target_tweet_replied(&self, tweet_id: &str) -> Result<(), LoopError>;
64
65    /// Record a reply to a target account (increments counter).
66    async fn record_target_reply(&self, account_id: &str) -> Result<(), LoopError>;
67
68    /// Get count of target replies sent today.
69    async fn count_target_replies_today(&self) -> Result<i64, LoopError>;
70
71    /// Log an action.
72    async fn log_action(
73        &self,
74        action_type: &str,
75        status: &str,
76        message: &str,
77    ) -> Result<(), LoopError>;
78}
79
80// ============================================================================
81// Target loop config
82// ============================================================================
83
84/// Configuration for the target monitoring loop.
85#[derive(Debug, Clone)]
86pub struct TargetLoopConfig {
87    /// Target account usernames (without @).
88    pub accounts: Vec<String>,
89    /// Maximum target replies per day.
90    pub max_target_replies_per_day: u32,
91    /// Whether this is a dry run.
92    pub dry_run: bool,
93}
94
95// ============================================================================
96// Target loop result
97// ============================================================================
98
99/// Result of processing a single target tweet.
100#[derive(Debug)]
101pub enum TargetResult {
102    /// Reply was sent (or would be in dry-run).
103    Replied {
104        tweet_id: String,
105        account: String,
106        reply_text: String,
107    },
108    /// Tweet was skipped.
109    Skipped { tweet_id: String, reason: String },
110    /// Processing failed.
111    Failed { tweet_id: String, error: String },
112}
113
114// ============================================================================
115// Target loop
116// ============================================================================
117
118/// Monitors target accounts and generates relationship-based replies.
119pub struct TargetLoop {
120    fetcher: Arc<dyn TargetTweetFetcher>,
121    user_mgr: Arc<dyn TargetUserManager>,
122    generator: Arc<dyn ReplyGenerator>,
123    safety: Arc<dyn SafetyChecker>,
124    storage: Arc<dyn TargetStorage>,
125    poster: Arc<dyn PostSender>,
126    config: TargetLoopConfig,
127}
128
129impl TargetLoop {
130    /// Create a new target monitoring loop.
131    #[allow(clippy::too_many_arguments)]
132    pub fn new(
133        fetcher: Arc<dyn TargetTweetFetcher>,
134        user_mgr: Arc<dyn TargetUserManager>,
135        generator: Arc<dyn ReplyGenerator>,
136        safety: Arc<dyn SafetyChecker>,
137        storage: Arc<dyn TargetStorage>,
138        poster: Arc<dyn PostSender>,
139        config: TargetLoopConfig,
140    ) -> Self {
141        Self {
142            fetcher,
143            user_mgr,
144            generator,
145            safety,
146            storage,
147            poster,
148            config,
149        }
150    }
151
152    /// Run the continuous target monitoring loop until cancellation.
153    pub async fn run(
154        &self,
155        cancel: CancellationToken,
156        scheduler: LoopScheduler,
157        schedule: Option<Arc<ActiveSchedule>>,
158    ) {
159        tracing::info!(
160            dry_run = self.config.dry_run,
161            accounts = self.config.accounts.len(),
162            max_replies = self.config.max_target_replies_per_day,
163            "Target monitoring loop started"
164        );
165
166        if self.config.accounts.is_empty() {
167            tracing::info!("No target accounts configured, target loop has nothing to do");
168            cancel.cancelled().await;
169            return;
170        }
171
172        let mut error_tracker = ConsecutiveErrorTracker::new(10, Duration::from_secs(300));
173
174        loop {
175            if cancel.is_cancelled() {
176                break;
177            }
178
179            if !schedule_gate(&schedule, &cancel).await {
180                break;
181            }
182
183            match self.run_iteration().await {
184                Ok(results) => {
185                    error_tracker.record_success();
186                    let replied = results
187                        .iter()
188                        .filter(|r| matches!(r, TargetResult::Replied { .. }))
189                        .count();
190                    let skipped = results
191                        .iter()
192                        .filter(|r| matches!(r, TargetResult::Skipped { .. }))
193                        .count();
194                    if !results.is_empty() {
195                        tracing::info!(
196                            total = results.len(),
197                            replied = replied,
198                            skipped = skipped,
199                            "Target iteration complete"
200                        );
201                    }
202                }
203                Err(e) => {
204                    let should_pause = error_tracker.record_error();
205                    tracing::warn!(
206                        error = %e,
207                        consecutive_errors = error_tracker.count(),
208                        "Target iteration failed"
209                    );
210
211                    if should_pause {
212                        tracing::warn!(
213                            pause_secs = error_tracker.pause_duration().as_secs(),
214                            "Pausing target loop due to consecutive errors"
215                        );
216                        tokio::select! {
217                            _ = cancel.cancelled() => break,
218                            _ = tokio::time::sleep(error_tracker.pause_duration()) => {},
219                        }
220                        error_tracker.reset();
221                        continue;
222                    }
223                }
224            }
225
226            tokio::select! {
227                _ = cancel.cancelled() => break,
228                _ = scheduler.tick() => {},
229            }
230        }
231
232        tracing::info!("Target monitoring loop stopped");
233    }
234
235    /// Run a single iteration across all target accounts.
236    pub async fn run_iteration(&self) -> Result<Vec<TargetResult>, LoopError> {
237        let mut all_results = Vec::new();
238
239        // Check daily limit
240        let replies_today = self.storage.count_target_replies_today().await?;
241        if replies_today >= self.config.max_target_replies_per_day as i64 {
242            tracing::debug!(
243                replies_today = replies_today,
244                limit = self.config.max_target_replies_per_day,
245                "Target reply daily limit reached"
246            );
247            return Ok(all_results);
248        }
249
250        let mut remaining_replies =
251            (self.config.max_target_replies_per_day as i64 - replies_today) as usize;
252
253        for username in &self.config.accounts {
254            if remaining_replies == 0 {
255                break;
256            }
257
258            match self.process_account(username, remaining_replies).await {
259                Ok(results) => {
260                    let replied_count = results
261                        .iter()
262                        .filter(|r| matches!(r, TargetResult::Replied { .. }))
263                        .count();
264                    remaining_replies = remaining_replies.saturating_sub(replied_count);
265                    all_results.extend(results);
266                }
267                Err(e) => {
268                    // AuthExpired is global — stop immediately instead of
269                    // failing N times with the same 401.
270                    if matches!(e, LoopError::AuthExpired) {
271                        tracing::error!(
272                            username = %username,
273                            "X API authentication expired, re-authenticate with `tuitbot init`"
274                        );
275                        return Err(e);
276                    }
277
278                    tracing::warn!(
279                        username = %username,
280                        error = %e,
281                        "Failed to process target account"
282                    );
283                }
284            }
285        }
286
287        Ok(all_results)
288    }
289
290    /// Process a single target account: resolve, fetch tweets, reply.
291    async fn process_account(
292        &self,
293        username: &str,
294        max_replies: usize,
295    ) -> Result<Vec<TargetResult>, LoopError> {
296        // Look up user
297        let (user_id, resolved_username) = self.user_mgr.lookup_user(username).await?;
298
299        // Upsert target account record
300        self.storage
301            .upsert_target_account(&user_id, &resolved_username)
302            .await?;
303
304        // Fetch recent tweets
305        let tweets = self.fetcher.fetch_user_tweets(&user_id).await?;
306        tracing::info!(
307            username = %resolved_username,
308            count = tweets.len(),
309            "Monitoring @{}, found {} new tweets",
310            resolved_username,
311            tweets.len(),
312        );
313
314        let mut results = Vec::new();
315
316        for tweet in tweets.iter().take(max_replies) {
317            let result = self
318                .process_target_tweet(tweet, &user_id, &resolved_username)
319                .await;
320            if matches!(result, TargetResult::Replied { .. }) {
321                results.push(result);
322                // Only reply to one tweet per account per iteration
323                break;
324            }
325            results.push(result);
326        }
327
328        Ok(results)
329    }
330
331    /// Process a single target tweet: dedup, safety check, generate reply, post.
332    async fn process_target_tweet(
333        &self,
334        tweet: &LoopTweet,
335        account_id: &str,
336        username: &str,
337    ) -> TargetResult {
338        // Check if already seen
339        match self.storage.target_tweet_exists(&tweet.id).await {
340            Ok(true) => {
341                return TargetResult::Skipped {
342                    tweet_id: tweet.id.clone(),
343                    reason: "already discovered".to_string(),
344                };
345            }
346            Ok(false) => {}
347            Err(e) => {
348                tracing::warn!(tweet_id = %tweet.id, error = %e, "Failed to check target tweet");
349            }
350        }
351
352        // Store the discovered tweet
353        let _ = self
354            .storage
355            .store_target_tweet(
356                &tweet.id,
357                account_id,
358                &tweet.text,
359                &tweet.created_at,
360                tweet.replies as i64,
361                tweet.likes as i64,
362                0.0,
363            )
364            .await;
365
366        // Safety checks
367        if self.safety.has_replied_to(&tweet.id).await {
368            return TargetResult::Skipped {
369                tweet_id: tweet.id.clone(),
370                reason: "already replied".to_string(),
371            };
372        }
373
374        if !self.safety.can_reply().await {
375            return TargetResult::Skipped {
376                tweet_id: tweet.id.clone(),
377                reason: "rate limited".to_string(),
378            };
379        }
380
381        // Generate reply with vault context (no product mention — genuine engagement)
382        let reply_output = match self
383            .generator
384            .generate_reply_with_rag(&tweet.text, username, false)
385            .await
386        {
387            Ok(output) => output,
388            Err(e) => {
389                return TargetResult::Failed {
390                    tweet_id: tweet.id.clone(),
391                    error: e.to_string(),
392                };
393            }
394        };
395        let reply_text = reply_output.text;
396
397        tracing::info!(
398            username = %username,
399            "Replied to target @{}",
400            username,
401        );
402
403        if self.config.dry_run {
404            tracing::info!(
405                "DRY RUN: Target @{} tweet {} -- Would reply: \"{}\"",
406                username,
407                tweet.id,
408                reply_text
409            );
410
411            let _ = self
412                .storage
413                .log_action(
414                    "target_reply",
415                    "dry_run",
416                    &format!("Reply to @{username}: {}", truncate(&reply_text, 50)),
417                )
418                .await;
419        } else {
420            if let Err(e) = self.poster.send_reply(&tweet.id, &reply_text).await {
421                return TargetResult::Failed {
422                    tweet_id: tweet.id.clone(),
423                    error: e.to_string(),
424                };
425            }
426
427            if let Err(e) = self.safety.record_reply(&tweet.id, &reply_text).await {
428                tracing::warn!(tweet_id = %tweet.id, error = %e, "Failed to record reply");
429            }
430
431            // Mark tweet as replied and update account stats
432            let _ = self.storage.mark_target_tweet_replied(&tweet.id).await;
433            let _ = self.storage.record_target_reply(account_id).await;
434
435            let _ = self
436                .storage
437                .log_action(
438                    "target_reply",
439                    "success",
440                    &format!("Replied to @{username}: {}", truncate(&reply_text, 50)),
441                )
442                .await;
443        }
444
445        TargetResult::Replied {
446            tweet_id: tweet.id.clone(),
447            account: username.to_string(),
448            reply_text,
449        }
450    }
451}
452
453/// Truncate a string for display.
454fn truncate(s: &str, max_len: usize) -> String {
455    if s.len() <= max_len {
456        s.to_string()
457    } else {
458        format!("{}...", &s[..max_len])
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use std::sync::atomic::{AtomicU32, Ordering};
466    use std::sync::Mutex;
467
468    // --- Mock implementations ---
469
470    struct MockFetcher {
471        tweets: Vec<LoopTweet>,
472    }
473
474    #[async_trait::async_trait]
475    impl TargetTweetFetcher for MockFetcher {
476        async fn fetch_user_tweets(&self, _user_id: &str) -> Result<Vec<LoopTweet>, LoopError> {
477            Ok(self.tweets.clone())
478        }
479    }
480
481    struct MockUserManager {
482        users: Vec<(String, String, String)>, // (username, user_id, resolved_username)
483    }
484
485    #[async_trait::async_trait]
486    impl TargetUserManager for MockUserManager {
487        async fn lookup_user(&self, username: &str) -> Result<(String, String), LoopError> {
488            for (uname, uid, resolved) in &self.users {
489                if uname == username {
490                    return Ok((uid.clone(), resolved.clone()));
491                }
492            }
493            Err(LoopError::Other(format!("user not found: {username}")))
494        }
495    }
496
497    struct MockGenerator {
498        reply: String,
499    }
500
501    #[async_trait::async_trait]
502    impl ReplyGenerator for MockGenerator {
503        async fn generate_reply(
504            &self,
505            _tweet_text: &str,
506            _author: &str,
507            _mention_product: bool,
508        ) -> Result<String, LoopError> {
509            Ok(self.reply.clone())
510        }
511    }
512
513    struct MockSafety {
514        can_reply: bool,
515        replied_ids: Mutex<Vec<String>>,
516    }
517
518    impl MockSafety {
519        fn new(can_reply: bool) -> Self {
520            Self {
521                can_reply,
522                replied_ids: Mutex::new(Vec::new()),
523            }
524        }
525    }
526
527    #[async_trait::async_trait]
528    impl SafetyChecker for MockSafety {
529        async fn can_reply(&self) -> bool {
530            self.can_reply
531        }
532        async fn has_replied_to(&self, tweet_id: &str) -> bool {
533            self.replied_ids
534                .lock()
535                .expect("lock")
536                .contains(&tweet_id.to_string())
537        }
538        async fn record_reply(&self, tweet_id: &str, _content: &str) -> Result<(), LoopError> {
539            self.replied_ids
540                .lock()
541                .expect("lock")
542                .push(tweet_id.to_string());
543            Ok(())
544        }
545    }
546
547    struct MockTargetStorage {
548        existing_tweets: Mutex<Vec<String>>,
549        replies_today: Mutex<i64>,
550    }
551
552    impl MockTargetStorage {
553        fn new() -> Self {
554            Self {
555                existing_tweets: Mutex::new(Vec::new()),
556                replies_today: Mutex::new(0),
557            }
558        }
559    }
560
561    #[async_trait::async_trait]
562    impl TargetStorage for MockTargetStorage {
563        async fn upsert_target_account(
564            &self,
565            _account_id: &str,
566            _username: &str,
567        ) -> Result<(), LoopError> {
568            Ok(())
569        }
570        async fn target_tweet_exists(&self, tweet_id: &str) -> Result<bool, LoopError> {
571            Ok(self
572                .existing_tweets
573                .lock()
574                .expect("lock")
575                .contains(&tweet_id.to_string()))
576        }
577        async fn store_target_tweet(
578            &self,
579            _tweet_id: &str,
580            _account_id: &str,
581            _content: &str,
582            _created_at: &str,
583            _reply_count: i64,
584            _like_count: i64,
585            _relevance_score: f64,
586        ) -> Result<(), LoopError> {
587            Ok(())
588        }
589        async fn mark_target_tweet_replied(&self, _tweet_id: &str) -> Result<(), LoopError> {
590            Ok(())
591        }
592        async fn record_target_reply(&self, _account_id: &str) -> Result<(), LoopError> {
593            *self.replies_today.lock().expect("lock") += 1;
594            Ok(())
595        }
596        async fn count_target_replies_today(&self) -> Result<i64, LoopError> {
597            Ok(*self.replies_today.lock().expect("lock"))
598        }
599        async fn log_action(
600            &self,
601            _action_type: &str,
602            _status: &str,
603            _message: &str,
604        ) -> Result<(), LoopError> {
605            Ok(())
606        }
607    }
608
609    struct MockPoster {
610        sent: Mutex<Vec<(String, String)>>,
611    }
612
613    impl MockPoster {
614        fn new() -> Self {
615            Self {
616                sent: Mutex::new(Vec::new()),
617            }
618        }
619        fn sent_count(&self) -> usize {
620            self.sent.lock().expect("lock").len()
621        }
622    }
623
624    #[async_trait::async_trait]
625    impl PostSender for MockPoster {
626        async fn send_reply(&self, tweet_id: &str, content: &str) -> Result<(), LoopError> {
627            self.sent
628                .lock()
629                .expect("lock")
630                .push((tweet_id.to_string(), content.to_string()));
631            Ok(())
632        }
633    }
634
635    fn test_tweet(id: &str, author: &str) -> LoopTweet {
636        LoopTweet {
637            id: id.to_string(),
638            text: format!("Interesting thoughts on tech from @{author}"),
639            author_id: format!("uid_{author}"),
640            author_username: author.to_string(),
641            author_followers: 5000,
642            created_at: "2026-01-01T00:00:00Z".to_string(),
643            likes: 10,
644            retweets: 2,
645            replies: 1,
646        }
647    }
648
649    fn default_config() -> TargetLoopConfig {
650        TargetLoopConfig {
651            accounts: vec!["alice".to_string()],
652            max_target_replies_per_day: 3,
653            dry_run: false,
654        }
655    }
656
657    fn build_loop(
658        tweets: Vec<LoopTweet>,
659        config: TargetLoopConfig,
660        storage: Arc<MockTargetStorage>,
661    ) -> (TargetLoop, Arc<MockPoster>) {
662        let poster = Arc::new(MockPoster::new());
663        let user_mgr = Arc::new(MockUserManager {
664            users: vec![(
665                "alice".to_string(),
666                "uid_alice".to_string(),
667                "alice".to_string(),
668            )],
669        });
670        let target_loop = TargetLoop::new(
671            Arc::new(MockFetcher { tweets }),
672            user_mgr,
673            Arc::new(MockGenerator {
674                reply: "Great point!".to_string(),
675            }),
676            Arc::new(MockSafety::new(true)),
677            storage,
678            poster.clone(),
679            config,
680        );
681        (target_loop, poster)
682    }
683
684    // --- Tests ---
685
686    #[tokio::test]
687    async fn empty_accounts_does_nothing() {
688        let storage = Arc::new(MockTargetStorage::new());
689        let mut config = default_config();
690        config.accounts = Vec::new();
691        let (target_loop, poster) = build_loop(Vec::new(), config, storage);
692
693        let results = target_loop.run_iteration().await.expect("iteration");
694        assert!(results.is_empty());
695        assert_eq!(poster.sent_count(), 0);
696    }
697
698    #[tokio::test]
699    async fn replies_to_target_tweet() {
700        let tweets = vec![test_tweet("tw1", "alice")];
701        let storage = Arc::new(MockTargetStorage::new());
702        let (target_loop, poster) = build_loop(tweets, default_config(), storage);
703
704        let results = target_loop.run_iteration().await.expect("iteration");
705        assert_eq!(results.len(), 1);
706        assert!(matches!(results[0], TargetResult::Replied { .. }));
707        assert_eq!(poster.sent_count(), 1);
708    }
709
710    #[tokio::test]
711    async fn skips_existing_target_tweet() {
712        let tweets = vec![test_tweet("tw1", "alice")];
713        let storage = Arc::new(MockTargetStorage::new());
714        storage
715            .existing_tweets
716            .lock()
717            .expect("lock")
718            .push("tw1".to_string());
719        let (target_loop, poster) = build_loop(tweets, default_config(), storage);
720
721        let results = target_loop.run_iteration().await.expect("iteration");
722        assert_eq!(results.len(), 1);
723        assert!(matches!(results[0], TargetResult::Skipped { .. }));
724        assert_eq!(poster.sent_count(), 0);
725    }
726
727    #[tokio::test]
728    async fn respects_daily_limit() {
729        let tweets = vec![test_tweet("tw1", "alice")];
730        let storage = Arc::new(MockTargetStorage::new());
731        *storage.replies_today.lock().expect("lock") = 3;
732        let (target_loop, poster) = build_loop(tweets, default_config(), storage);
733
734        let results = target_loop.run_iteration().await.expect("iteration");
735        assert!(results.is_empty());
736        assert_eq!(poster.sent_count(), 0);
737    }
738
739    #[tokio::test]
740    async fn dry_run_does_not_post() {
741        let tweets = vec![test_tweet("tw1", "alice")];
742        let storage = Arc::new(MockTargetStorage::new());
743        let mut config = default_config();
744        config.dry_run = true;
745        let (target_loop, poster) = build_loop(tweets, config, storage);
746
747        let results = target_loop.run_iteration().await.expect("iteration");
748        assert_eq!(results.len(), 1);
749        assert!(matches!(results[0], TargetResult::Replied { .. }));
750        assert_eq!(poster.sent_count(), 0);
751    }
752
753    #[test]
754    fn truncate_short_string() {
755        assert_eq!(truncate("hello", 10), "hello");
756    }
757
758    #[test]
759    fn truncate_long_string() {
760        assert_eq!(truncate("hello world", 5), "hello...");
761    }
762
763    // --- AuthExpired-aware mock ---
764
765    struct MockAuthExpiredUserManager {
766        lookup_count: AtomicU32,
767        error: LoopError,
768    }
769
770    impl MockAuthExpiredUserManager {
771        fn auth_expired() -> Self {
772            Self {
773                lookup_count: AtomicU32::new(0),
774                error: LoopError::AuthExpired,
775            }
776        }
777    }
778
779    #[async_trait::async_trait]
780    impl TargetUserManager for MockAuthExpiredUserManager {
781        async fn lookup_user(&self, _username: &str) -> Result<(String, String), LoopError> {
782            self.lookup_count.fetch_add(1, Ordering::SeqCst);
783            Err(match &self.error {
784                LoopError::AuthExpired => LoopError::AuthExpired,
785                LoopError::Other(msg) => LoopError::Other(msg.clone()),
786                _ => unreachable!(),
787            })
788        }
789    }
790
791    /// A user manager where the first lookup fails and the second succeeds.
792    struct MockPartialFailUserManager {
793        call_count: AtomicU32,
794    }
795
796    #[async_trait::async_trait]
797    impl TargetUserManager for MockPartialFailUserManager {
798        async fn lookup_user(&self, username: &str) -> Result<(String, String), LoopError> {
799            let n = self.call_count.fetch_add(1, Ordering::SeqCst);
800            if n == 0 {
801                Err(LoopError::Other("transient failure".to_string()))
802            } else {
803                Ok((format!("uid_{username}"), username.to_string()))
804            }
805        }
806    }
807
808    #[tokio::test]
809    async fn auth_expired_stops_iteration() {
810        let user_mgr = Arc::new(MockAuthExpiredUserManager::auth_expired());
811        let storage = Arc::new(MockTargetStorage::new());
812        let poster = Arc::new(MockPoster::new());
813
814        let mut config = default_config();
815        config.accounts = vec![
816            "alice".to_string(),
817            "bob".to_string(),
818            "charlie".to_string(),
819        ];
820
821        let target_loop = TargetLoop::new(
822            Arc::new(MockFetcher { tweets: vec![] }),
823            user_mgr.clone(),
824            Arc::new(MockGenerator {
825                reply: "Great!".to_string(),
826            }),
827            Arc::new(MockSafety::new(true)),
828            storage,
829            poster,
830            config,
831        );
832
833        let result = target_loop.run_iteration().await;
834        assert!(result.is_err());
835        assert!(matches!(result.unwrap_err(), LoopError::AuthExpired));
836        // Only one lookup should have been attempted — the loop exits early.
837        assert_eq!(user_mgr.lookup_count.load(Ordering::SeqCst), 1);
838    }
839
840    #[test]
841    fn target_loop_config_debug() {
842        let config = TargetLoopConfig {
843            accounts: vec!["alice".to_string(), "bob".to_string()],
844            max_target_replies_per_day: 5,
845            dry_run: false,
846        };
847        let debug = format!("{config:?}");
848        assert!(debug.contains("alice"));
849        assert!(debug.contains("5"));
850    }
851
852    #[test]
853    fn target_loop_config_clone() {
854        let config = default_config();
855        let clone = config.clone();
856        assert_eq!(clone.accounts, config.accounts);
857        assert_eq!(
858            clone.max_target_replies_per_day,
859            config.max_target_replies_per_day
860        );
861        assert_eq!(clone.dry_run, config.dry_run);
862    }
863
864    #[test]
865    fn target_result_debug_all_variants() {
866        let r = TargetResult::Replied {
867            tweet_id: "t1".to_string(),
868            account: "alice".to_string(),
869            reply_text: "hi".to_string(),
870        };
871        assert!(format!("{r:?}").contains("Replied"));
872
873        let r = TargetResult::Skipped {
874            tweet_id: "t2".to_string(),
875            reason: "dup".to_string(),
876        };
877        assert!(format!("{r:?}").contains("Skipped"));
878
879        let r = TargetResult::Failed {
880            tweet_id: "t3".to_string(),
881            error: "oops".to_string(),
882        };
883        assert!(format!("{r:?}").contains("Failed"));
884    }
885
886    #[test]
887    fn truncate_exact_boundary() {
888        assert_eq!(truncate("hello", 5), "hello");
889    }
890
891    #[test]
892    fn truncate_empty() {
893        assert_eq!(truncate("", 10), "");
894    }
895
896    #[test]
897    fn truncate_zero() {
898        assert_eq!(truncate("hello", 0), "...");
899    }
900
901    #[test]
902    fn truncate_one_char() {
903        assert_eq!(truncate("hello", 1), "h...");
904    }
905
906    #[test]
907    fn target_loop_config_default_values() {
908        let config = TargetLoopConfig {
909            accounts: vec![],
910            max_target_replies_per_day: 0,
911            dry_run: true,
912        };
913        assert!(config.accounts.is_empty());
914        assert_eq!(config.max_target_replies_per_day, 0);
915        assert!(config.dry_run);
916    }
917
918    #[tokio::test]
919    async fn replies_only_to_first_tweet_per_account() {
920        // TargetLoop replies to only one tweet per account per iteration.
921        let tweets = vec![
922            test_tweet("tw1", "alice"),
923            test_tweet("tw2", "alice"),
924            test_tweet("tw3", "alice"),
925        ];
926        let storage = Arc::new(MockTargetStorage::new());
927        let (target_loop, poster) = build_loop(tweets, default_config(), storage);
928
929        let results = target_loop.run_iteration().await.expect("iteration");
930        // Only 1 reply per account — first tweet gets replied, loop breaks
931        let replied = results
932            .iter()
933            .filter(|r| matches!(r, TargetResult::Replied { .. }))
934            .count();
935        assert_eq!(replied, 1);
936        assert_eq!(poster.sent_count(), 1);
937    }
938
939    #[tokio::test]
940    async fn skips_when_safety_cant_reply() {
941        let tweets = vec![test_tweet("tw1", "alice")];
942        let storage = Arc::new(MockTargetStorage::new());
943        let poster = Arc::new(MockPoster::new());
944        let user_mgr = Arc::new(MockUserManager {
945            users: vec![(
946                "alice".to_string(),
947                "uid_alice".to_string(),
948                "alice".to_string(),
949            )],
950        });
951
952        let target_loop = TargetLoop::new(
953            Arc::new(MockFetcher { tweets }),
954            user_mgr,
955            Arc::new(MockGenerator {
956                reply: "Great!".to_string(),
957            }),
958            Arc::new(MockSafety::new(false)), // can_reply = false
959            storage,
960            poster.clone(),
961            default_config(),
962        );
963
964        let results = target_loop.run_iteration().await.expect("iteration");
965        assert_eq!(results.len(), 1);
966        assert!(matches!(
967            &results[0],
968            TargetResult::Skipped { reason, .. } if reason == "rate limited"
969        ));
970        assert_eq!(poster.sent_count(), 0);
971    }
972
973    #[tokio::test]
974    async fn skips_when_already_replied() {
975        let tweets = vec![test_tweet("tw1", "alice")];
976        let storage = Arc::new(MockTargetStorage::new());
977        let poster = Arc::new(MockPoster::new());
978        let safety = Arc::new(MockSafety::new(true));
979        // Pre-mark tw1 as replied
980        safety.record_reply("tw1", "already replied").await.unwrap();
981
982        let user_mgr = Arc::new(MockUserManager {
983            users: vec![(
984                "alice".to_string(),
985                "uid_alice".to_string(),
986                "alice".to_string(),
987            )],
988        });
989
990        let target_loop = TargetLoop::new(
991            Arc::new(MockFetcher { tweets }),
992            user_mgr,
993            Arc::new(MockGenerator {
994                reply: "Great!".to_string(),
995            }),
996            safety,
997            storage,
998            poster.clone(),
999            default_config(),
1000        );
1001
1002        let results = target_loop.run_iteration().await.expect("iteration");
1003        assert_eq!(results.len(), 1);
1004        assert!(matches!(
1005            &results[0],
1006            TargetResult::Skipped { reason, .. } if reason == "already replied"
1007        ));
1008        assert_eq!(poster.sent_count(), 0);
1009    }
1010
1011    #[tokio::test]
1012    async fn no_tweets_returns_empty_results() {
1013        let storage = Arc::new(MockTargetStorage::new());
1014        let (target_loop, poster) = build_loop(vec![], default_config(), storage);
1015
1016        let results = target_loop.run_iteration().await.expect("iteration");
1017        assert!(results.is_empty());
1018        assert_eq!(poster.sent_count(), 0);
1019    }
1020
1021    #[tokio::test]
1022    async fn non_auth_error_continues_iteration() {
1023        let user_mgr = Arc::new(MockPartialFailUserManager {
1024            call_count: AtomicU32::new(0),
1025        });
1026        let storage = Arc::new(MockTargetStorage::new());
1027        let poster = Arc::new(MockPoster::new());
1028
1029        let mut config = default_config();
1030        config.accounts = vec!["alice".to_string(), "bob".to_string()];
1031
1032        let target_loop = TargetLoop::new(
1033            Arc::new(MockFetcher {
1034                tweets: vec![test_tweet("tw1", "bob")],
1035            }),
1036            user_mgr.clone(),
1037            Arc::new(MockGenerator {
1038                reply: "Nice!".to_string(),
1039            }),
1040            Arc::new(MockSafety::new(true)),
1041            storage,
1042            poster.clone(),
1043            config,
1044        );
1045
1046        let results = target_loop.run_iteration().await.expect("should succeed");
1047        // First account fails with Other, second succeeds — both should be attempted.
1048        assert_eq!(user_mgr.call_count.load(Ordering::SeqCst), 2);
1049        // Second account produces a reply.
1050        assert_eq!(results.len(), 1);
1051        assert!(matches!(results[0], TargetResult::Replied { .. }));
1052        assert_eq!(poster.sent_count(), 1);
1053    }
1054}