1use super::models::{
2 SearchPaginationMeta, SearchRecentResult, SearchResultMeta, SearchTweet, SearchUser,
3 SearchUsersResult,
4};
5use anyhow::Result;
6
7#[derive(Debug, Clone)]
9pub struct SearchRecentArgs {
10 pub query: String,
11 pub limit: Option<usize>,
12 pub cursor: Option<String>,
13}
14
15#[derive(Debug, Clone)]
17pub struct SearchUsersArgs {
18 pub query: String,
19 pub limit: Option<usize>,
20 pub cursor: Option<String>,
21}
22
23pub trait SearchClient {
25 fn search_recent(&self, args: &SearchRecentArgs) -> Result<SearchRecentResult>;
27 fn search_users(&self, args: &SearchUsersArgs) -> Result<SearchUsersResult>;
29}
30
31pub struct MockSearchClient {
33 pub tweets: Vec<SearchTweet>,
34 pub users: Vec<SearchUser>,
35}
36
37impl MockSearchClient {
38 pub fn new() -> Self {
40 Self {
41 tweets: Vec::new(),
42 users: Vec::new(),
43 }
44 }
45
46 pub fn with_tweet_fixtures(count: usize) -> Self {
48 let tweets = (0..count)
49 .map(|i| {
50 let mut tweet = SearchTweet::new(format!("fixture_tweet_{}", i));
51 tweet.text = Some(format!("Fixture tweet text {}", i));
52 tweet.author_id = Some(format!("fixture_user_{}", i));
53 tweet.created_at = Some("2024-01-01T00:00:00Z".to_string());
54 tweet
55 })
56 .collect();
57 Self {
58 tweets,
59 users: Vec::new(),
60 }
61 }
62
63 pub fn with_user_fixtures(count: usize) -> Self {
65 let users = (0..count)
66 .map(|i| {
67 let mut user = SearchUser::new(format!("fixture_user_{}", i));
68 user.name = Some(format!("Fixture User {}", i));
69 user.username = Some(format!("fixture_user_{}", i));
70 user.description = Some(format!("A fixture user {}", i));
71 user
72 })
73 .collect();
74 Self {
75 tweets: Vec::new(),
76 users,
77 }
78 }
79}
80
81impl Default for MockSearchClient {
82 fn default() -> Self {
83 Self::new()
84 }
85}
86
87impl SearchClient for MockSearchClient {
88 fn search_recent(&self, args: &SearchRecentArgs) -> Result<SearchRecentResult> {
89 let limit = args.limit.unwrap_or(10);
90 let offset = parse_cursor(&args.cursor);
91 let tweets: Vec<SearchTweet> = self
92 .tweets
93 .iter()
94 .skip(offset)
95 .take(limit)
96 .cloned()
97 .collect();
98 let result_count = tweets.len();
99 let next_token = if result_count == limit && offset + limit < self.tweets.len() {
100 Some(format!("cursor_{}", offset + limit))
101 } else {
102 None
103 };
104 let prev_token = if offset > 0 {
105 Some(format!("cursor_{}", offset.saturating_sub(limit)))
106 } else {
107 None
108 };
109 Ok(SearchRecentResult {
110 tweets,
111 meta: Some(SearchResultMeta {
112 pagination: SearchPaginationMeta {
113 next_token,
114 prev_token,
115 result_count,
116 },
117 }),
118 })
119 }
120
121 fn search_users(&self, args: &SearchUsersArgs) -> Result<SearchUsersResult> {
122 let limit = args.limit.unwrap_or(10);
123 let offset = parse_cursor(&args.cursor);
124 let users: Vec<SearchUser> = self
125 .users
126 .iter()
127 .skip(offset)
128 .take(limit)
129 .cloned()
130 .collect();
131 let result_count = users.len();
132 let next_token = if result_count == limit && offset + limit < self.users.len() {
133 Some(format!("cursor_{}", offset + limit))
134 } else {
135 None
136 };
137 let prev_token = if offset > 0 {
138 Some(format!("cursor_{}", offset.saturating_sub(limit)))
139 } else {
140 None
141 };
142 Ok(SearchUsersResult {
143 users,
144 meta: Some(SearchResultMeta {
145 pagination: SearchPaginationMeta {
146 next_token,
147 prev_token,
148 result_count,
149 },
150 }),
151 })
152 }
153}
154
155pub struct SearchCommand;
157
158impl SearchCommand {
159 pub fn new() -> Self {
160 Self
161 }
162
163 pub fn search_recent(&self, args: SearchRecentArgs) -> Result<SearchRecentResult> {
165 if let Ok(error_type) = std::env::var("XCOM_SIMULATE_ERROR") {
167 if error_type == "rate_limit" {
168 return Err(anyhow::anyhow!("Rate limit exceeded"));
169 }
170 }
171
172 let limit = args.limit.unwrap_or(10);
173
174 let offset = parse_cursor(&args.cursor);
176
177 let tweets: Vec<SearchTweet> = (offset..(offset + limit))
179 .map(|i| {
180 let mut tweet = SearchTweet::new(format!("tweet_{}", i));
181 tweet.text = Some(format!("{}: Tweet text {}", args.query, i));
182 tweet.author_id = Some(format!("user_{}", i));
183 tweet.created_at = Some("2024-01-01T00:00:00Z".to_string());
184 tweet
185 })
186 .collect();
187
188 let result_count = tweets.len();
189 let next_token = if result_count == limit {
190 Some(format!("cursor_{}", offset + limit))
191 } else {
192 None
193 };
194 let prev_token = if offset > 0 {
195 Some(format!("cursor_{}", offset.saturating_sub(limit)))
196 } else {
197 None
198 };
199
200 let meta = Some(SearchResultMeta {
201 pagination: SearchPaginationMeta {
202 next_token,
203 prev_token,
204 result_count,
205 },
206 });
207
208 Ok(SearchRecentResult { tweets, meta })
209 }
210
211 pub fn search_users(&self, args: SearchUsersArgs) -> Result<SearchUsersResult> {
213 if let Ok(error_type) = std::env::var("XCOM_SIMULATE_ERROR") {
215 if error_type == "rate_limit" {
216 return Err(anyhow::anyhow!("Rate limit exceeded"));
217 }
218 }
219
220 let limit = args.limit.unwrap_or(10);
221
222 let offset = parse_cursor(&args.cursor);
224
225 let users: Vec<SearchUser> = (offset..(offset + limit))
227 .map(|i| {
228 let mut user = SearchUser::new(format!("user_{}", i));
229 user.name = Some(format!("{} User {}", args.query, i));
230 user.username = Some(format!(
231 "{}_user_{}",
232 args.query.to_lowercase().replace(' ', "_"),
233 i
234 ));
235 user.description = Some(format!("A user matching '{}' query", args.query));
236 user
237 })
238 .collect();
239
240 let result_count = users.len();
241 let next_token = if result_count == limit {
242 Some(format!("cursor_{}", offset + limit))
243 } else {
244 None
245 };
246 let prev_token = if offset > 0 {
247 Some(format!("cursor_{}", offset.saturating_sub(limit)))
248 } else {
249 None
250 };
251
252 let meta = Some(SearchResultMeta {
253 pagination: SearchPaginationMeta {
254 next_token,
255 prev_token,
256 result_count,
257 },
258 });
259
260 Ok(SearchUsersResult { users, meta })
261 }
262}
263
264impl Default for SearchCommand {
265 fn default() -> Self {
266 Self::new()
267 }
268}
269
270fn parse_cursor(cursor: &Option<String>) -> usize {
272 if let Some(cursor) = cursor {
273 cursor
274 .strip_prefix("cursor_")
275 .and_then(|s| s.parse::<usize>().ok())
276 .unwrap_or(0)
277 } else {
278 0
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn test_search_recent_basic() {
288 let cmd = SearchCommand::new();
289 let args = SearchRecentArgs {
290 query: "hello world".to_string(),
291 limit: Some(5),
292 cursor: None,
293 };
294
295 let result = cmd.search_recent(args).unwrap();
296 assert_eq!(result.tweets.len(), 5);
297 assert!(result.meta.is_some());
298 let meta = result.meta.unwrap();
299 assert_eq!(meta.pagination.result_count, 5);
300 assert_eq!(meta.pagination.next_token, Some("cursor_5".to_string()));
301 assert!(meta.pagination.prev_token.is_none());
302 }
303
304 #[test]
305 fn test_search_recent_with_cursor() {
306 let cmd = SearchCommand::new();
307 let args = SearchRecentArgs {
308 query: "rust".to_string(),
309 limit: Some(5),
310 cursor: Some("cursor_10".to_string()),
311 };
312
313 let result = cmd.search_recent(args).unwrap();
314 assert_eq!(result.tweets.len(), 5);
315 let meta = result.meta.unwrap();
316 assert_eq!(meta.pagination.next_token, Some("cursor_15".to_string()));
317 assert_eq!(meta.pagination.prev_token, Some("cursor_5".to_string()));
318 }
319
320 #[test]
321 fn test_search_recent_tweet_contains_query() {
322 let cmd = SearchCommand::new();
323 let args = SearchRecentArgs {
324 query: "rustlang".to_string(),
325 limit: Some(3),
326 cursor: None,
327 };
328
329 let result = cmd.search_recent(args).unwrap();
330 for tweet in &result.tweets {
331 let text = tweet.text.as_ref().unwrap();
332 assert!(
333 text.contains("rustlang"),
334 "Tweet text should contain query: {}",
335 text
336 );
337 }
338 }
339
340 #[test]
341 fn test_search_recent_default_limit() {
342 let cmd = SearchCommand::new();
343 let args = SearchRecentArgs {
344 query: "test".to_string(),
345 limit: None,
346 cursor: None,
347 };
348
349 let result = cmd.search_recent(args).unwrap();
350 assert_eq!(result.tweets.len(), 10); }
352
353 #[test]
354 fn test_search_users_basic() {
355 let cmd = SearchCommand::new();
356 let args = SearchUsersArgs {
357 query: "alice".to_string(),
358 limit: Some(5),
359 cursor: None,
360 };
361
362 let result = cmd.search_users(args).unwrap();
363 assert_eq!(result.users.len(), 5);
364 assert!(result.meta.is_some());
365 let meta = result.meta.unwrap();
366 assert_eq!(meta.pagination.result_count, 5);
367 assert_eq!(meta.pagination.next_token, Some("cursor_5".to_string()));
368 }
369
370 #[test]
371 fn test_search_users_with_cursor() {
372 let cmd = SearchCommand::new();
373 let args = SearchUsersArgs {
374 query: "bob".to_string(),
375 limit: Some(5),
376 cursor: Some("cursor_5".to_string()),
377 };
378
379 let result = cmd.search_users(args).unwrap();
380 assert_eq!(result.users.len(), 5);
381 let meta = result.meta.unwrap();
382 assert_eq!(meta.pagination.next_token, Some("cursor_10".to_string()));
383 assert_eq!(meta.pagination.prev_token, Some("cursor_0".to_string()));
384 }
385
386 #[test]
387 fn test_search_users_user_fields() {
388 let cmd = SearchCommand::new();
389 let args = SearchUsersArgs {
390 query: "developer".to_string(),
391 limit: Some(3),
392 cursor: None,
393 };
394
395 let result = cmd.search_users(args).unwrap();
396 for user in &result.users {
397 assert!(!user.id.is_empty());
398 assert!(user.name.is_some());
399 assert!(user.username.is_some());
400 assert!(user.description.is_some());
401 }
402 }
403
404 #[test]
405 fn test_search_users_default_limit() {
406 let cmd = SearchCommand::new();
407 let args = SearchUsersArgs {
408 query: "test".to_string(),
409 limit: None,
410 cursor: None,
411 };
412
413 let result = cmd.search_users(args).unwrap();
414 assert_eq!(result.users.len(), 10); }
416
417 #[test]
418 fn test_parse_cursor_none() {
419 assert_eq!(parse_cursor(&None), 0);
420 }
421
422 #[test]
423 fn test_parse_cursor_valid() {
424 assert_eq!(parse_cursor(&Some("cursor_42".to_string())), 42);
425 }
426
427 #[test]
428 fn test_parse_cursor_invalid() {
429 assert_eq!(parse_cursor(&Some("invalid".to_string())), 0);
430 }
431}