1use anyhow::Result;
2
3use super::models::{TimelineArgs, TimelineKind, TimelineMeta, TimelinePagination, TimelineResult};
4use crate::tweets::Tweet;
5
6#[derive(Debug)]
8pub enum TimelineError {
9 AuthRequired,
11 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 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
40struct UserInfo {
42 #[allow(dead_code)]
43 id: String,
44 #[allow(dead_code)]
45 handle: String,
46}
47
48#[derive(Debug, Clone)]
50struct ResolvedUser {
51 id: String,
52 handle: String,
53}
54
55pub struct TimelineCommand;
57
58impl TimelineCommand {
59 pub fn new() -> Self {
61 Self
62 }
63
64 fn resolve_user_by_handle(&self, handle: &str) -> Result<ResolvedUser, TimelineError> {
69 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 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 fn resolve_me(&self) -> Result<UserInfo, TimelineError> {
90 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 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 pub fn get(&self, args: TimelineArgs) -> Result<TimelineResult, TimelineError> {
114 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}