1use crate::api::{ApiClient, ApiError};
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::time::{SystemTime, UNIX_EPOCH};
13
14const DEFAULT_TTL_SECONDS: u64 = 86400;
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct CachedUser {
20 pub id: String,
21 pub name: String,
22 pub real_name: Option<String>,
23 pub display_name: Option<String>,
24 pub deleted: bool,
25 pub is_bot: bool,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
30pub struct WorkspaceCache {
31 pub team_id: String,
32 pub updated_at: u64,
33 pub users: HashMap<String, CachedUser>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct UsersCacheFile {
39 pub caches: HashMap<String, WorkspaceCache>,
40}
41
42impl UsersCacheFile {
43 pub fn new() -> Self {
45 Self {
46 caches: HashMap::new(),
47 }
48 }
49
50 pub fn default_path() -> Result<PathBuf, String> {
52 directories::ProjectDirs::from("", "", "slack-rs")
53 .map(|dirs| dirs.config_dir().join("users_cache.json"))
54 .ok_or_else(|| "Could not determine config directory".to_string())
55 }
56
57 pub fn load(path: &Path) -> Result<Self, String> {
59 if !path.exists() {
60 return Ok(Self::new());
61 }
62
63 let content =
64 fs::read_to_string(path).map_err(|e| format!("Failed to read cache file: {}", e))?;
65 serde_json::from_str(&content).map_err(|e| format!("Failed to parse cache file: {}", e))
66 }
67
68 pub fn save(&self, path: &Path) -> Result<(), String> {
70 if let Some(parent) = path.parent() {
71 fs::create_dir_all(parent)
72 .map_err(|e| format!("Failed to create cache directory: {}", e))?;
73 }
74
75 let content = serde_json::to_string_pretty(self)
76 .map_err(|e| format!("Failed to serialize cache: {}", e))?;
77 fs::write(path, content).map_err(|e| format!("Failed to write cache file: {}", e))
78 }
79
80 pub fn get_workspace(&self, team_id: &str) -> Option<&WorkspaceCache> {
82 self.caches.get(team_id)
83 }
84
85 pub fn set_workspace(&mut self, cache: WorkspaceCache) {
87 self.caches.insert(cache.team_id.clone(), cache);
88 }
89
90 pub fn is_expired(&self, team_id: &str, ttl_seconds: u64) -> bool {
92 match self.get_workspace(team_id) {
93 Some(cache) => {
94 let now = SystemTime::now()
95 .duration_since(UNIX_EPOCH)
96 .unwrap()
97 .as_secs();
98 now - cache.updated_at > ttl_seconds
99 }
100 None => true, }
102 }
103}
104
105impl Default for UsersCacheFile {
106 fn default() -> Self {
107 Self::new()
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq)]
113pub enum MentionFormat {
114 DisplayName,
115 RealName,
116 Username,
117}
118
119impl std::str::FromStr for MentionFormat {
120 type Err = String;
121
122 fn from_str(s: &str) -> Result<Self, Self::Err> {
123 match s {
124 "display_name" => Ok(Self::DisplayName),
125 "real_name" => Ok(Self::RealName),
126 "username" => Ok(Self::Username),
127 _ => Err(format!("Invalid format: {}", s)),
128 }
129 }
130}
131
132pub async fn fetch_all_users(
142 client: &ApiClient,
143 team_id: String,
144) -> Result<WorkspaceCache, ApiError> {
145 let mut all_users = HashMap::new();
146 let mut cursor: Option<String> = None;
147 let limit = 200;
148
149 loop {
150 let mut params = HashMap::new();
151 params.insert("limit".to_string(), serde_json::json!(limit));
152 if let Some(c) = &cursor {
153 params.insert("cursor".to_string(), serde_json::json!(c));
154 }
155
156 let response = client
157 .call_method(crate::api::ApiMethod::UsersList, params)
158 .await?;
159
160 if let Some(members) = response.data.get("members").and_then(|v| v.as_array()) {
162 for member in members {
163 if let Some(user) = parse_user_from_json(member) {
164 all_users.insert(user.id.clone(), user);
165 }
166 }
167 }
168
169 cursor = response
171 .data
172 .get("response_metadata")
173 .and_then(|v| v.get("next_cursor"))
174 .and_then(|v| v.as_str())
175 .filter(|s| !s.is_empty())
176 .map(|s| s.to_string());
177
178 if cursor.is_none() {
179 break;
180 }
181 }
182
183 let now = SystemTime::now()
184 .duration_since(UNIX_EPOCH)
185 .unwrap()
186 .as_secs();
187
188 Ok(WorkspaceCache {
189 team_id,
190 updated_at: now,
191 users: all_users,
192 })
193}
194
195fn parse_user_from_json(value: &serde_json::Value) -> Option<CachedUser> {
197 let id = value.get("id")?.as_str()?.to_string();
198 let name = value.get("name")?.as_str()?.to_string();
199
200 let profile = value.get("profile");
201 let display_name = profile
202 .and_then(|p| p.get("display_name"))
203 .and_then(|v| v.as_str())
204 .filter(|s| !s.is_empty())
205 .map(|s| s.to_string());
206
207 let real_name = profile
208 .and_then(|p| p.get("real_name"))
209 .and_then(|v| v.as_str())
210 .filter(|s| !s.is_empty())
211 .map(|s| s.to_string());
212
213 let deleted = value
214 .get("deleted")
215 .and_then(|v| v.as_bool())
216 .unwrap_or(false);
217 let is_bot = value
218 .get("is_bot")
219 .and_then(|v| v.as_bool())
220 .unwrap_or(false);
221
222 Some(CachedUser {
223 id,
224 name,
225 real_name,
226 display_name,
227 deleted,
228 is_bot,
229 })
230}
231
232pub fn resolve_mentions(text: &str, cache: &WorkspaceCache, format: MentionFormat) -> String {
242 let mention_regex = Regex::new(r"<@(U[A-Z0-9]+)(?:\|[^>]+)?>").unwrap();
243
244 mention_regex
245 .replace_all(text, |caps: ®ex::Captures| {
246 let user_id = &caps[1];
247 match cache.users.get(user_id) {
248 Some(user) => {
249 let name = match format {
250 MentionFormat::DisplayName => user
251 .display_name
252 .as_deref()
253 .or(Some(&user.name))
254 .unwrap_or(&user.name),
255 MentionFormat::RealName => user.real_name.as_deref().unwrap_or(&user.name),
256 MentionFormat::Username => &user.name,
257 };
258
259 format!("@{}", name)
260 }
261 None => caps[0].to_string(), }
263 })
264 .to_string()
265}
266
267pub async fn update_cache(client: &ApiClient, team_id: String, force: bool) -> Result<(), String> {
278 let cache_path = UsersCacheFile::default_path()?;
279 let mut cache_file = UsersCacheFile::load(&cache_path)?;
280
281 if !force && !cache_file.is_expired(&team_id, DEFAULT_TTL_SECONDS) {
283 return Err("Cache is still valid. Use --force to update anyway.".to_string());
284 }
285
286 let workspace_cache = fetch_all_users(client, team_id)
288 .await
289 .map_err(|e| format!("Failed to fetch users: {}", e))?;
290
291 cache_file.set_workspace(workspace_cache);
293 cache_file.save(&cache_path)?;
294
295 Ok(())
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use tempfile::TempDir;
302 use wiremock::{
303 matchers::{method, path},
304 Mock, MockServer, ResponseTemplate,
305 };
306
307 #[test]
308 fn test_cache_file_new() {
309 let cache = UsersCacheFile::new();
310 assert!(cache.caches.is_empty());
311 }
312
313 #[test]
314 fn test_cache_file_save_load() {
315 let temp_dir = TempDir::new().unwrap();
316 let cache_path = temp_dir.path().join("users_cache.json");
317
318 let mut cache_file = UsersCacheFile::new();
319 let workspace = WorkspaceCache {
320 team_id: "T123".to_string(),
321 updated_at: 1700000000,
322 users: HashMap::new(),
323 };
324 cache_file.set_workspace(workspace);
325
326 cache_file.save(&cache_path).unwrap();
327 assert!(cache_path.exists());
328
329 let loaded = UsersCacheFile::load(&cache_path).unwrap();
330 assert_eq!(cache_file, loaded);
331 }
332
333 #[test]
334 fn test_cache_expiration() {
335 let mut cache_file = UsersCacheFile::new();
336 let now = SystemTime::now()
337 .duration_since(UNIX_EPOCH)
338 .unwrap()
339 .as_secs();
340
341 let workspace = WorkspaceCache {
343 team_id: "T123".to_string(),
344 updated_at: now - 1000, users: HashMap::new(),
346 };
347 cache_file.set_workspace(workspace);
348
349 assert!(!cache_file.is_expired("T123", 86400)); let old_workspace = WorkspaceCache {
353 team_id: "T456".to_string(),
354 updated_at: now - 100000, users: HashMap::new(),
356 };
357 cache_file.set_workspace(old_workspace);
358
359 assert!(cache_file.is_expired("T456", 86400));
360
361 assert!(cache_file.is_expired("T999", 86400));
363 }
364
365 #[test]
366 fn test_mention_resolution() {
367 let mut users = HashMap::new();
368 users.insert(
369 "U123".to_string(),
370 CachedUser {
371 id: "U123".to_string(),
372 name: "john".to_string(),
373 real_name: Some("John Doe".to_string()),
374 display_name: Some("johnd".to_string()),
375 deleted: false,
376 is_bot: false,
377 },
378 );
379 users.insert(
380 "U456".to_string(),
381 CachedUser {
382 id: "U456".to_string(),
383 name: "jane".to_string(),
384 real_name: Some("Jane Smith".to_string()),
385 display_name: None,
386 deleted: true,
387 is_bot: false,
388 },
389 );
390
391 let cache = WorkspaceCache {
392 team_id: "T123".to_string(),
393 updated_at: 1700000000,
394 users,
395 };
396
397 let text = "Hello <@U123> and <@U456>!";
399 let result = resolve_mentions(text, &cache, MentionFormat::DisplayName);
400 assert_eq!(result, "Hello @johnd and @jane!");
401
402 let result = resolve_mentions(text, &cache, MentionFormat::RealName);
404 assert_eq!(result, "Hello @John Doe and @Jane Smith!");
405
406 let result = resolve_mentions(text, &cache, MentionFormat::Username);
408 assert_eq!(result, "Hello @john and @jane!");
409
410 let text_unknown = "Hello <@U999>!";
412 let result = resolve_mentions(text_unknown, &cache, MentionFormat::DisplayName);
413 assert_eq!(result, "Hello <@U999>!");
414
415 let text_pipe = "Hello <@U123|john>!";
417 let result = resolve_mentions(text_pipe, &cache, MentionFormat::DisplayName);
418 assert_eq!(result, "Hello @johnd!");
419 }
420
421 #[test]
422 fn test_parse_user_from_json() {
423 let json = serde_json::json!({
424 "id": "U123",
425 "name": "john",
426 "profile": {
427 "display_name": "johnd",
428 "real_name": "John Doe"
429 },
430 "deleted": false,
431 "is_bot": false
432 });
433
434 let user = parse_user_from_json(&json).unwrap();
435 assert_eq!(user.id, "U123");
436 assert_eq!(user.name, "john");
437 assert_eq!(user.display_name, Some("johnd".to_string()));
438 assert_eq!(user.real_name, Some("John Doe".to_string()));
439 assert!(!user.deleted);
440 assert!(!user.is_bot);
441 }
442
443 #[test]
444 fn test_mention_format_from_str() {
445 use std::str::FromStr;
446 assert_eq!(
447 MentionFormat::from_str("display_name"),
448 Ok(MentionFormat::DisplayName)
449 );
450 assert_eq!(
451 MentionFormat::from_str("real_name"),
452 Ok(MentionFormat::RealName)
453 );
454 assert_eq!(
455 MentionFormat::from_str("username"),
456 Ok(MentionFormat::Username)
457 );
458 assert!(MentionFormat::from_str("invalid").is_err());
459 }
460
461 #[tokio::test]
462 async fn test_fetch_all_users_with_pagination() {
463 let mock_server = MockServer::start().await;
464
465 let first_response = serde_json::json!({
467 "ok": true,
468 "members": [
469 {
470 "id": "U001",
471 "name": "user1",
472 "profile": {
473 "display_name": "User One",
474 "real_name": "User One"
475 },
476 "deleted": false,
477 "is_bot": false
478 },
479 {
480 "id": "U002",
481 "name": "user2",
482 "profile": {
483 "display_name": "User Two",
484 "real_name": "User Two"
485 },
486 "deleted": false,
487 "is_bot": false
488 }
489 ],
490 "response_metadata": {
491 "next_cursor": "cursor123"
492 }
493 });
494
495 let second_response = serde_json::json!({
497 "ok": true,
498 "members": [
499 {
500 "id": "U003",
501 "name": "user3",
502 "profile": {
503 "display_name": "User Three",
504 "real_name": "User Three"
505 },
506 "deleted": false,
507 "is_bot": false
508 }
509 ],
510 "response_metadata": {
511 "next_cursor": ""
512 }
513 });
514
515 Mock::given(method("GET"))
517 .and(path("/users.list"))
518 .respond_with(
519 ResponseTemplate::new(200)
520 .set_body_json(&first_response)
521 .append_header("content-type", "application/json"),
522 )
523 .up_to_n_times(1)
524 .mount(&mock_server)
525 .await;
526
527 Mock::given(method("GET"))
529 .and(path("/users.list"))
530 .respond_with(
531 ResponseTemplate::new(200)
532 .set_body_json(&second_response)
533 .append_header("content-type", "application/json"),
534 )
535 .mount(&mock_server)
536 .await;
537
538 let client =
539 crate::api::ApiClient::new_with_base_url("test-token".to_string(), mock_server.uri());
540
541 let result = fetch_all_users(&client, "T123".to_string()).await;
542 assert!(result.is_ok());
543
544 let cache = result.unwrap();
545 assert_eq!(cache.team_id, "T123");
546 assert_eq!(cache.users.len(), 3);
547 assert!(cache.users.contains_key("U001"));
548 assert!(cache.users.contains_key("U002"));
549 assert!(cache.users.contains_key("U003"));
550 }
551}