Skip to main content

xcom_rs/timeline/
commands.rs

1use anyhow::Result;
2
3use super::models::{TimelineArgs, TimelineKind, TimelineMeta, TimelinePagination, TimelineResult};
4use crate::tweets::Tweet;
5
6/// Error type for timeline operations
7#[derive(Debug)]
8pub enum TimelineError {
9    /// Authentication is required but not available
10    AuthRequired,
11    /// API call failed with a classified error
12    ApiError(crate::tweets::ClassifiedError),
13}
14
15impl std::fmt::Display for TimelineError {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        match self {
18            TimelineError::AuthRequired => write!(
19                f,
20                "Authentication required. Run 'xcom-rs auth login' to authenticate."
21            ),
22            TimelineError::ApiError(e) => write!(f, "API error: {}", e),
23        }
24    }
25}
26
27impl std::error::Error for TimelineError {}
28
29impl TimelineError {
30    /// Convert to ErrorCode for protocol
31    pub fn to_error_code(&self) -> crate::protocol::ErrorCode {
32        use crate::protocol::ErrorCode;
33        match self {
34            TimelineError::AuthRequired => ErrorCode::AuthRequired,
35            TimelineError::ApiError(e) => e.to_error_code(),
36        }
37    }
38}
39
40/// Simulated user info representing an authenticated user
41struct UserInfo {
42    #[allow(dead_code)]
43    id: String,
44    #[allow(dead_code)]
45    handle: String,
46}
47
48/// Resolved user ID for a given handle
49#[derive(Debug, Clone)]
50struct ResolvedUser {
51    id: String,
52    handle: String,
53}
54
55/// Main timeline command handler
56pub struct TimelineCommand;
57
58impl TimelineCommand {
59    /// Create a new timeline command handler
60    pub fn new() -> Self {
61        Self
62    }
63
64    /// Resolve a user by handle to get their user ID.
65    /// In production this would call GET /2/users/by/username/{handle}.
66    /// For testing, the resolved user ID can be overridden via XCOM_TEST_RESOLVE_USER_{HANDLE}_ID
67    /// environment variable. If not set, a deterministic stub ID is generated.
68    fn resolve_user_by_handle(&self, handle: &str) -> Result<ResolvedUser, TimelineError> {
69        // Allow test overrides via environment variable for specific handles
70        let env_key = format!("XCOM_TEST_RESOLVE_USER_{}_ID", handle.to_uppercase());
71        let user_id = if let Ok(id) = std::env::var(&env_key) {
72            id
73        } else {
74            // Generate a deterministic stub ID for the handle (production would fetch from API)
75            format!("user_id_for_{}", handle.to_lowercase())
76        };
77
78        tracing::debug!(handle = %handle, user_id = %user_id, "Resolved user handle to ID");
79
80        Ok(ResolvedUser {
81            id: user_id,
82            handle: handle.to_string(),
83        })
84    }
85
86    /// Resolve the authenticated user's ID.
87    /// In a real implementation this would call GET /2/users/me.
88    /// For now, we simulate it via an environment variable or use a stub.
89    fn resolve_me(&self) -> Result<UserInfo, TimelineError> {
90        // Allow overriding via environment for testing
91        if let Ok(user_id) = std::env::var("XCOM_TEST_USER_ID") {
92            let handle =
93                std::env::var("XCOM_TEST_USER_HANDLE").unwrap_or_else(|_| "testuser".to_string());
94            return Ok(UserInfo {
95                id: user_id,
96                handle,
97            });
98        }
99
100        // In production this would call the X API; here we simulate a default authenticated user
101        // Returning AuthRequired when no credentials are present (simulated by env var absence)
102        if std::env::var("XCOM_AUTHENTICATED").is_err() {
103            return Err(TimelineError::AuthRequired);
104        }
105
106        Ok(UserInfo {
107            id: "me_user_id".to_string(),
108            handle: "me".to_string(),
109        })
110    }
111
112    /// Retrieve a timeline based on the given arguments.
113    pub fn get(&self, args: TimelineArgs) -> Result<TimelineResult, TimelineError> {
114        // Check for simulated errors via environment variables (for testing)
115        if let Ok(error_type) = std::env::var("XCOM_SIMULATE_ERROR") {
116            use crate::tweets::ClassifiedError;
117            match error_type.as_str() {
118                "rate_limit" => {
119                    let retry_after = std::env::var("XCOM_RETRY_AFTER_MS")
120                        .ok()
121                        .and_then(|s| s.parse::<u64>().ok())
122                        .unwrap_or(60000);
123                    return Err(TimelineError::ApiError(
124                        ClassifiedError::from_status_code(429, "Rate limit exceeded".to_string())
125                            .with_retry_after(retry_after),
126                    ));
127                }
128                "server_error" => {
129                    return Err(TimelineError::ApiError(ClassifiedError::from_status_code(
130                        500,
131                        "Internal server error".to_string(),
132                    )));
133                }
134                "auth_required" => {
135                    return Err(TimelineError::AuthRequired);
136                }
137                _ => {}
138            }
139        }
140
141        match &args.kind {
142            TimelineKind::Home => self.get_home(&args),
143            TimelineKind::Mentions => self.get_mentions(&args),
144            TimelineKind::User { handle } => self.get_user_tweets(handle.clone(), &args),
145        }
146    }
147
148    /// Build simulated tweets for testing/stub purposes.
149    fn build_stub_tweets(prefix: &str, offset: usize, limit: usize) -> Vec<Tweet> {
150        (offset..(offset + limit))
151            .map(|i| {
152                let mut tweet = Tweet::new(format!("{}_{}", prefix, i));
153                tweet.text = Some(format!("{} tweet text {}", prefix, i));
154                tweet.author_id = Some(format!("user_{}", i));
155                tweet.created_at = Some("2024-01-01T00:00:00Z".to_string());
156                tweet
157            })
158            .collect()
159    }
160
161    /// Parse cursor to extract offset.
162    fn parse_cursor_offset(cursor: &Option<String>) -> usize {
163        if let Some(c) = cursor {
164            c.strip_prefix("next_token_")
165                .and_then(|s| s.parse::<usize>().ok())
166                .unwrap_or(0)
167        } else {
168            0
169        }
170    }
171
172    /// Build pagination metadata.
173    fn build_pagination(offset: usize, limit: usize, count: usize) -> Option<TimelineMeta> {
174        let next_token = if count == limit {
175            Some(format!("next_token_{}", offset + limit))
176        } else {
177            None
178        };
179
180        let previous_token = if offset > 0 {
181            Some(format!("next_token_{}", offset.saturating_sub(limit)))
182        } else {
183            None
184        };
185
186        if next_token.is_some() || previous_token.is_some() {
187            Some(TimelineMeta {
188                pagination: TimelinePagination {
189                    next_token,
190                    previous_token,
191                },
192            })
193        } else {
194            None
195        }
196    }
197
198    fn get_home(&self, args: &TimelineArgs) -> Result<TimelineResult, TimelineError> {
199        // Resolve authenticated user (would call GET /2/users/me in production)
200        let _user = self.resolve_me()?;
201
202        let offset = Self::parse_cursor_offset(&args.cursor);
203        let tweets = Self::build_stub_tweets("home", offset, args.limit);
204        let count = tweets.len();
205        let meta = Self::build_pagination(offset, args.limit, count);
206
207        Ok(TimelineResult { tweets, meta })
208    }
209
210    fn get_mentions(&self, args: &TimelineArgs) -> Result<TimelineResult, TimelineError> {
211        // Resolve authenticated user (would call GET /2/users/me in production)
212        let _user = self.resolve_me()?;
213
214        let offset = Self::parse_cursor_offset(&args.cursor);
215        let tweets = Self::build_stub_tweets("mention", offset, args.limit);
216        let count = tweets.len();
217        let meta = Self::build_pagination(offset, args.limit, count);
218
219        Ok(TimelineResult { tweets, meta })
220    }
221
222    fn get_user_tweets(
223        &self,
224        handle: String,
225        args: &TimelineArgs,
226    ) -> Result<TimelineResult, TimelineError> {
227        // Step 1: Resolve handle to user ID
228        // In production: GET /2/users/by/username/{handle}
229        // For testing: uses XCOM_TEST_RESOLVE_USER_{HANDLE}_ID env var or deterministic stub
230        let resolved = self.resolve_user_by_handle(&handle)?;
231
232        tracing::info!(
233            handle = %handle,
234            user_id = %resolved.id,
235            "Resolved handle to user ID, fetching tweets"
236        );
237
238        // Step 2: Fetch tweets for the resolved user ID
239        // In production: GET /2/users/{id}/tweets
240        let offset = Self::parse_cursor_offset(&args.cursor);
241        let tweets = Self::build_stub_tweets(&resolved.handle, offset, args.limit);
242        let count = tweets.len();
243        let meta = Self::build_pagination(offset, args.limit, count);
244
245        Ok(TimelineResult { tweets, meta })
246    }
247}
248
249impl Default for TimelineCommand {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::timeline::models::TimelineKind;
259
260    fn set_authenticated() {
261        std::env::set_var("XCOM_AUTHENTICATED", "1");
262        std::env::set_var("XCOM_TEST_USER_ID", "test_user_id");
263        std::env::set_var("XCOM_TEST_USER_HANDLE", "testhandle");
264    }
265
266    fn unset_authenticated() {
267        std::env::remove_var("XCOM_AUTHENTICATED");
268        std::env::remove_var("XCOM_TEST_USER_ID");
269        std::env::remove_var("XCOM_TEST_USER_HANDLE");
270        std::env::remove_var("XCOM_SIMULATE_ERROR");
271        std::env::remove_var("XCOM_RETRY_AFTER_MS");
272    }
273
274    #[test]
275    fn test_home_timeline_basic() {
276        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
277        set_authenticated();
278
279        let cmd = TimelineCommand::new();
280        let args = TimelineArgs {
281            kind: TimelineKind::Home,
282            limit: 5,
283            cursor: None,
284        };
285
286        let result = cmd.get(args).unwrap();
287        assert_eq!(result.tweets.len(), 5);
288        assert!(result.tweets.iter().all(|t| t.id.starts_with("home_")));
289
290        unset_authenticated();
291    }
292
293    #[test]
294    fn test_mentions_timeline_basic() {
295        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
296        set_authenticated();
297
298        let cmd = TimelineCommand::new();
299        let args = TimelineArgs {
300            kind: TimelineKind::Mentions,
301            limit: 3,
302            cursor: None,
303        };
304
305        let result = cmd.get(args).unwrap();
306        assert_eq!(result.tweets.len(), 3);
307        assert!(result.tweets.iter().all(|t| t.id.starts_with("mention_")));
308
309        unset_authenticated();
310    }
311
312    #[test]
313    fn test_user_timeline_basic() {
314        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
315        unset_authenticated();
316
317        let cmd = TimelineCommand::new();
318        let args = TimelineArgs {
319            kind: TimelineKind::User {
320                handle: "johndoe".to_string(),
321            },
322            limit: 4,
323            cursor: None,
324        };
325
326        let result = cmd.get(args).unwrap();
327        assert_eq!(result.tweets.len(), 4);
328        assert!(result.tweets.iter().all(|t| t.id.starts_with("johndoe_")));
329
330        unset_authenticated();
331    }
332
333    #[test]
334    fn test_home_timeline_with_cursor() {
335        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
336        set_authenticated();
337
338        let cmd = TimelineCommand::new();
339        let args = TimelineArgs {
340            kind: TimelineKind::Home,
341            limit: 5,
342            cursor: Some("next_token_5".to_string()),
343        };
344
345        let result = cmd.get(args).unwrap();
346        assert_eq!(result.tweets.len(), 5);
347        // Tweets should start from offset 5
348        assert_eq!(result.tweets[0].id, "home_5");
349
350        unset_authenticated();
351    }
352
353    #[test]
354    fn test_timeline_pagination_next_token() {
355        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
356        set_authenticated();
357
358        let cmd = TimelineCommand::new();
359        let args = TimelineArgs {
360            kind: TimelineKind::Home,
361            limit: 10,
362            cursor: None,
363        };
364
365        let result = cmd.get(args).unwrap();
366        assert!(result.meta.is_some());
367        let meta = result.meta.unwrap();
368        assert_eq!(
369            meta.pagination.next_token,
370            Some("next_token_10".to_string())
371        );
372        assert!(meta.pagination.previous_token.is_none());
373
374        unset_authenticated();
375    }
376
377    #[test]
378    fn test_timeline_auth_required() {
379        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
380        unset_authenticated();
381
382        let cmd = TimelineCommand::new();
383        let args = TimelineArgs {
384            kind: TimelineKind::Home,
385            limit: 10,
386            cursor: None,
387        };
388
389        let result = cmd.get(args);
390        assert!(result.is_err());
391        match result.unwrap_err() {
392            TimelineError::AuthRequired => {}
393            e => panic!("Expected AuthRequired, got: {}", e),
394        }
395    }
396
397    #[test]
398    fn test_timeline_rate_limit_error() {
399        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
400        set_authenticated();
401        std::env::set_var("XCOM_SIMULATE_ERROR", "rate_limit");
402        std::env::set_var("XCOM_RETRY_AFTER_MS", "5000");
403
404        let cmd = TimelineCommand::new();
405        let args = TimelineArgs {
406            kind: TimelineKind::Home,
407            limit: 10,
408            cursor: None,
409        };
410
411        let result = cmd.get(args);
412        assert!(result.is_err());
413        match result.unwrap_err() {
414            TimelineError::ApiError(e) => {
415                assert!(e.is_retryable);
416                assert_eq!(e.retry_after_ms, Some(5000));
417            }
418            e => panic!("Expected ApiError, got: {}", e),
419        }
420
421        unset_authenticated();
422    }
423
424    #[test]
425    fn test_timeline_pagination_with_previous_token() {
426        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
427        set_authenticated();
428
429        let cmd = TimelineCommand::new();
430        let args = TimelineArgs {
431            kind: TimelineKind::Mentions,
432            limit: 5,
433            cursor: Some("next_token_10".to_string()),
434        };
435
436        let result = cmd.get(args).unwrap();
437        assert!(result.meta.is_some());
438        let meta = result.meta.unwrap();
439        // Should have both next and previous tokens when in the middle
440        assert!(meta.pagination.next_token.is_some());
441        assert!(meta.pagination.previous_token.is_some());
442        assert_eq!(
443            meta.pagination.previous_token,
444            Some("next_token_5".to_string())
445        );
446
447        unset_authenticated();
448    }
449
450    #[test]
451    fn test_user_timeline_resolves_handle_to_id() {
452        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
453        unset_authenticated();
454
455        let cmd = TimelineCommand::new();
456        // Verify handle resolution: tweets should use the resolved handle
457        let args = TimelineArgs {
458            kind: TimelineKind::User {
459                handle: "XDev".to_string(),
460            },
461            limit: 5,
462            cursor: None,
463        };
464
465        let result = cmd.get(args).unwrap();
466        assert_eq!(result.tweets.len(), 5);
467        // Tweets are built from the resolved handle (case preserved)
468        // The resolve step maps handle -> user_id, then uses handle for stub tweets
469        assert!(
470            result.tweets.iter().all(|t| t.id.starts_with("XDev_")),
471            "Expected tweet IDs to start with 'XDev_', got: {:?}",
472            result.tweets.iter().map(|t| &t.id).collect::<Vec<_>>()
473        );
474
475        unset_authenticated();
476    }
477
478    #[test]
479    fn test_user_timeline_resolves_handle_with_env_override() {
480        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
481        unset_authenticated();
482        // Override the resolved user ID for a specific handle
483        std::env::set_var(
484            "XCOM_TEST_RESOLVE_USER_TESTHANDLE_ID",
485            "overridden_user_id_123",
486        );
487
488        let cmd = TimelineCommand::new();
489        let args = TimelineArgs {
490            kind: TimelineKind::User {
491                handle: "testhandle".to_string(),
492            },
493            limit: 3,
494            cursor: None,
495        };
496
497        let result = cmd.get(args).unwrap();
498        assert_eq!(result.tweets.len(), 3);
499
500        std::env::remove_var("XCOM_TEST_RESOLVE_USER_TESTHANDLE_ID");
501        unset_authenticated();
502    }
503
504    #[test]
505    fn test_pagination_response_uses_snake_case_tokens() {
506        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
507        set_authenticated();
508
509        let cmd = TimelineCommand::new();
510        let args = TimelineArgs {
511            kind: TimelineKind::Home,
512            limit: 10,
513            cursor: None,
514        };
515
516        let result = cmd.get(args).unwrap();
517        assert!(result.meta.is_some());
518        let meta = result.meta.unwrap();
519        // Verify that pagination tokens are present (snake_case in JSON via serde field names)
520        assert!(meta.pagination.next_token.is_some());
521        assert_eq!(
522            meta.pagination.next_token,
523            Some("next_token_10".to_string())
524        );
525
526        // Serialize to JSON and verify field names are snake_case
527        let json = serde_json::to_string(&meta).unwrap();
528        assert!(
529            json.contains("next_token"),
530            "JSON should use next_token (snake_case), got: {}",
531            json
532        );
533        assert!(
534            !json.contains("nextToken"),
535            "JSON should NOT use nextToken (camelCase), got: {}",
536            json
537        );
538
539        unset_authenticated();
540    }
541}