Skip to main content

slack_rs/commands/
users_cache.rs

1//! Users cache for mention resolution
2//!
3//! Provides caching for user information to enable mention resolution
4//! without repeated API calls. Cache is stored per workspace with TTL.
5
6use 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
14/// Default cache TTL in seconds (24 hours)
15const DEFAULT_TTL_SECONDS: u64 = 86400;
16
17/// Cached user information
18#[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/// Workspace-specific user cache
29#[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/// Users cache file containing multiple workspace caches
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct UsersCacheFile {
39    pub caches: HashMap<String, WorkspaceCache>,
40}
41
42impl UsersCacheFile {
43    /// Create a new empty cache file
44    pub fn new() -> Self {
45        Self {
46            caches: HashMap::new(),
47        }
48    }
49
50    /// Get the default cache file path
51    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    /// Load cache from file
58    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    /// Save cache to file
69    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    /// Get workspace cache
81    pub fn get_workspace(&self, team_id: &str) -> Option<&WorkspaceCache> {
82        self.caches.get(team_id)
83    }
84
85    /// Set workspace cache
86    pub fn set_workspace(&mut self, cache: WorkspaceCache) {
87        self.caches.insert(cache.team_id.clone(), cache);
88    }
89
90    /// Check if workspace cache is expired
91    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, // No cache means expired
101        }
102    }
103}
104
105impl Default for UsersCacheFile {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111/// Format option for mention resolution
112#[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
132/// Fetch all users from Slack API with pagination
133///
134/// # Arguments
135/// * `client` - API client with authentication
136/// * `team_id` - Team ID for the workspace
137///
138/// # Returns
139/// * `Ok(WorkspaceCache)` with all users
140/// * `Err(ApiError)` if the operation fails
141pub 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        // Extract users from response
161        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        // Check for next cursor
170        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
195/// Parse user from JSON value
196fn 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
232/// Resolve mentions in text using cache
233///
234/// # Arguments
235/// * `text` - Input text containing mentions
236/// * `cache` - Workspace cache with user information
237/// * `format` - Format to use for resolved mentions
238///
239/// # Returns
240/// Text with mentions resolved to user names
241pub 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: &regex::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(), // Keep original if not found
262            }
263        })
264        .to_string()
265}
266
267/// Update users cache for a workspace
268///
269/// # Arguments
270/// * `client` - API client
271/// * `team_id` - Team ID
272/// * `force` - Force update even if cache is not expired
273///
274/// # Returns
275/// * `Ok(())` if successful
276/// * `Err(String)` if the operation fails
277pub 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    // Check if update is needed
282    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    // Fetch users
287    let workspace_cache = fetch_all_users(client, team_id)
288        .await
289        .map_err(|e| format!("Failed to fetch users: {}", e))?;
290
291    // Update cache
292    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        // Recent cache should not be expired
342        let workspace = WorkspaceCache {
343            team_id: "T123".to_string(),
344            updated_at: now - 1000, // 1000 seconds ago
345            users: HashMap::new(),
346        };
347        cache_file.set_workspace(workspace);
348
349        assert!(!cache_file.is_expired("T123", 86400)); // 24 hours TTL
350
351        // Old cache should be expired
352        let old_workspace = WorkspaceCache {
353            team_id: "T456".to_string(),
354            updated_at: now - 100000, // > 24 hours ago
355            users: HashMap::new(),
356        };
357        cache_file.set_workspace(old_workspace);
358
359        assert!(cache_file.is_expired("T456", 86400));
360
361        // Non-existent cache should be expired
362        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        // Test display_name format
398        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        // Test real_name format
403        let result = resolve_mentions(text, &cache, MentionFormat::RealName);
404        assert_eq!(result, "Hello @John Doe and @Jane Smith!");
405
406        // Test username format
407        let result = resolve_mentions(text, &cache, MentionFormat::Username);
408        assert_eq!(result, "Hello @john and @jane!");
409
410        // Test unknown user
411        let text_unknown = "Hello <@U999>!";
412        let result = resolve_mentions(text_unknown, &cache, MentionFormat::DisplayName);
413        assert_eq!(result, "Hello <@U999>!");
414
415        // Test mention with pipe notation
416        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        // First page response
466        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        // Second page response
496        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        // Use up() to respond to first request
516        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        // Use up() to respond to second request
528        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}