Skip to main content

slack_rs/profile/
storage.rs

1use crate::profile::types::ProfilesConfig;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum StorageError {
9    #[error("IO error: {0}")]
10    Io(#[from] io::Error),
11    #[error("JSON serialization error: {0}")]
12    Json(#[from] serde_json::Error),
13    #[error("Config directory not found")]
14    ConfigDirNotFound,
15}
16
17pub type Result<T> = std::result::Result<T, StorageError>;
18
19/// Get the legacy config file path (slack-cli) for migration purposes
20fn legacy_config_path() -> Result<PathBuf> {
21    directories::ProjectDirs::from("", "", "slack-cli")
22        .map(|dirs| dirs.config_dir().join("profiles.json"))
23        .ok_or(StorageError::ConfigDirNotFound)
24}
25
26/// Get the default config file path using the OS config directory
27pub fn default_config_path() -> Result<PathBuf> {
28    // Check for environment variable override (used in testing)
29    if let Ok(config_path) = std::env::var("SLACK_RS_CONFIG_PATH") {
30        return Ok(PathBuf::from(config_path));
31    }
32
33    let dirs = directories::ProjectDirs::from("", "", "slack-rs")
34        .ok_or(StorageError::ConfigDirNotFound)?;
35    let config_dir = dirs.config_dir();
36
37    // Create directory if it doesn't exist
38    fs::create_dir_all(config_dir)?;
39
40    Ok(config_dir.join("profiles.json"))
41}
42
43/// Migrate legacy config file to new path if needed
44/// This function is only called when using the default config path
45fn migrate_legacy_config_internal() -> Result<bool> {
46    // Get new default path
47    let new_path = default_config_path()?;
48
49    // If new path already exists, no migration needed
50    if new_path.exists() {
51        return Ok(false);
52    }
53
54    // Try to get legacy path
55    let legacy_path = match legacy_config_path() {
56        Ok(path) => path,
57        Err(_) => return Ok(false),
58    };
59
60    // If legacy path doesn't exist, no migration needed
61    if !legacy_path.exists() {
62        return Ok(false);
63    }
64
65    // Create parent directory for new path if it doesn't exist
66    if let Some(parent) = new_path.parent() {
67        fs::create_dir_all(parent)?;
68    }
69
70    // Try to rename (move) the file first
71    match fs::rename(&legacy_path, &new_path) {
72        Ok(_) => Ok(true),
73        Err(_) => {
74            // If rename fails (e.g., different filesystems), copy and keep the old file
75            let content = fs::read_to_string(&legacy_path)?;
76            fs::write(&new_path, content)?;
77            Ok(true)
78        }
79    }
80}
81
82/// Migrate legacy config file for a specific path (used for testing)
83/// Returns true if migration was performed
84#[cfg(test)]
85fn migrate_legacy_config(legacy_path: &Path, new_path: &Path) -> Result<bool> {
86    // If new path already exists, no migration needed
87    if new_path.exists() {
88        return Ok(false);
89    }
90
91    // If legacy path doesn't exist, no migration needed
92    if !legacy_path.exists() {
93        return Ok(false);
94    }
95
96    // Create parent directory for new path if it doesn't exist
97    if let Some(parent) = new_path.parent() {
98        fs::create_dir_all(parent)?;
99    }
100
101    // Try to rename (move) the file first
102    match fs::rename(legacy_path, new_path) {
103        Ok(_) => Ok(true),
104        Err(_) => {
105            // If rename fails (e.g., different filesystems), copy and keep the old file
106            let content = fs::read_to_string(legacy_path)?;
107            fs::write(new_path, content)?;
108            Ok(true)
109        }
110    }
111}
112
113/// Load profiles config from a file
114pub fn load_config(path: &Path) -> Result<ProfilesConfig> {
115    // Try to migrate legacy config if this is the default path
116    // Only attempt migration when using default_config_path
117    if let Ok(default_path) = default_config_path() {
118        if path == default_path {
119            let _ = migrate_legacy_config_internal();
120        }
121    }
122
123    if !path.exists() {
124        return Ok(ProfilesConfig::new());
125    }
126
127    let content = fs::read_to_string(path)?;
128    let config: ProfilesConfig = serde_json::from_str(&content)?;
129    Ok(config)
130}
131
132/// Save profiles config to a file
133pub fn save_config(path: &Path, config: &ProfilesConfig) -> Result<()> {
134    // Create parent directory if it doesn't exist
135    if let Some(parent) = path.parent() {
136        fs::create_dir_all(parent)?;
137    }
138
139    let content = serde_json::to_string_pretty(config)?;
140    fs::write(path, content)?;
141    Ok(())
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::profile::types::Profile;
148    use tempfile::TempDir;
149
150    #[test]
151    fn test_save_and_load_config() {
152        let temp_dir = TempDir::new().unwrap();
153        let config_path = temp_dir.path().join("profiles.json");
154
155        let mut config = ProfilesConfig::new();
156        config.set(
157            "default".to_string(),
158            Profile {
159                team_id: "T123".to_string(),
160                user_id: "U456".to_string(),
161                team_name: Some("Test Team".to_string()),
162                user_name: Some("Test User".to_string()),
163                client_id: None,
164                redirect_uri: None,
165                scopes: None,
166                bot_scopes: None,
167                user_scopes: None,
168                default_token_type: None,
169            },
170        );
171
172        // Save config
173        save_config(&config_path, &config).unwrap();
174        assert!(config_path.exists());
175
176        // Load config
177        let loaded = load_config(&config_path).unwrap();
178        assert_eq!(config, loaded);
179    }
180
181    #[test]
182    fn test_load_nonexistent_config() {
183        let temp_dir = TempDir::new().unwrap();
184        let config_path = temp_dir.path().join("nonexistent.json");
185
186        let loaded = load_config(&config_path).unwrap();
187        assert_eq!(loaded, ProfilesConfig::new());
188    }
189
190    #[test]
191    fn test_save_creates_parent_directory() {
192        let temp_dir = TempDir::new().unwrap();
193        let config_path = temp_dir.path().join("nested/dir/profiles.json");
194
195        let config = ProfilesConfig::new();
196        save_config(&config_path, &config).unwrap();
197
198        assert!(config_path.exists());
199        assert!(config_path.parent().unwrap().exists());
200    }
201
202    #[test]
203    fn test_load_save_round_trip() {
204        let temp_dir = TempDir::new().unwrap();
205        let config_path = temp_dir.path().join("profiles.json");
206
207        let mut config = ProfilesConfig::new();
208        config.set(
209            "profile1".to_string(),
210            Profile {
211                team_id: "T1".to_string(),
212                user_id: "U1".to_string(),
213                team_name: None,
214                user_name: None,
215                client_id: None,
216                redirect_uri: None,
217                scopes: None,
218                bot_scopes: None,
219                user_scopes: None,
220                default_token_type: None,
221            },
222        );
223        config.set(
224            "profile2".to_string(),
225            Profile {
226                team_id: "T2".to_string(),
227                user_id: "U2".to_string(),
228                team_name: Some("Team 2".to_string()),
229                user_name: Some("User 2".to_string()),
230                client_id: None,
231                redirect_uri: None,
232                scopes: None,
233                bot_scopes: None,
234                user_scopes: None,
235                default_token_type: None,
236            },
237        );
238
239        save_config(&config_path, &config).unwrap();
240        let loaded = load_config(&config_path).unwrap();
241        assert_eq!(config, loaded);
242    }
243
244    #[test]
245    fn test_default_config_path() {
246        // Just verify it doesn't panic and returns something
247        let result = default_config_path();
248        match result {
249            Ok(path) => {
250                assert!(path.to_string_lossy().contains("slack-rs"));
251                assert!(path.to_string_lossy().contains("profiles.json"));
252            }
253            Err(StorageError::ConfigDirNotFound) => {
254                // This might happen in some test environments
255            }
256            Err(e) => panic!("Unexpected error: {}", e),
257        }
258    }
259
260    #[test]
261    fn test_migrate_legacy_config_path() {
262        let temp_dir = TempDir::new().unwrap();
263        let legacy_path = temp_dir.path().join("legacy").join("profiles.json");
264        let new_path = temp_dir.path().join("new").join("profiles.json");
265
266        // Create legacy config
267        let mut config = ProfilesConfig::new();
268        config.set(
269            "legacy".to_string(),
270            Profile {
271                team_id: "T123".to_string(),
272                user_id: "U456".to_string(),
273                team_name: Some("Legacy Team".to_string()),
274                user_name: Some("Legacy User".to_string()),
275                client_id: None,
276                redirect_uri: None,
277                scopes: None,
278                bot_scopes: None,
279                user_scopes: None,
280                default_token_type: None,
281            },
282        );
283        fs::create_dir_all(legacy_path.parent().unwrap()).unwrap();
284        save_config(&legacy_path, &config).unwrap();
285        assert!(legacy_path.exists());
286
287        // Perform migration
288        let migrated = migrate_legacy_config(&legacy_path, &new_path).unwrap();
289        assert!(migrated);
290        assert!(new_path.exists());
291
292        // Verify migrated content
293        let loaded = load_config(&new_path).unwrap();
294        assert_eq!(config, loaded);
295
296        // Test that migration is skipped if new path exists
297        let legacy_path2 = temp_dir.path().join("legacy2").join("profiles.json");
298        fs::create_dir_all(legacy_path2.parent().unwrap()).unwrap();
299        save_config(&legacy_path2, &config).unwrap();
300
301        let migrated_again = migrate_legacy_config(&legacy_path2, &new_path).unwrap();
302        assert!(!migrated_again);
303    }
304
305    #[test]
306    fn test_load_config_with_migration() {
307        let temp_dir = TempDir::new().unwrap();
308        let legacy_path = temp_dir.path().join("legacy").join("profiles.json");
309        let new_path = temp_dir.path().join("new").join("profiles.json");
310
311        // Create legacy config
312        let mut config = ProfilesConfig::new();
313        config.set(
314            "test".to_string(),
315            Profile {
316                team_id: "T999".to_string(),
317                user_id: "U888".to_string(),
318                team_name: None,
319                user_name: None,
320                client_id: None,
321                redirect_uri: None,
322                scopes: None,
323                bot_scopes: None,
324                user_scopes: None,
325                default_token_type: None,
326            },
327        );
328        fs::create_dir_all(legacy_path.parent().unwrap()).unwrap();
329        save_config(&legacy_path, &config).unwrap();
330
331        // Manually trigger migration by calling migrate_legacy_config
332        migrate_legacy_config(&legacy_path, &new_path).unwrap();
333
334        // Load from new path should work
335        let loaded = load_config(&new_path).unwrap();
336        assert_eq!(config, loaded);
337    }
338}