spotify_cli/storage/
unified_token.rs

1//! Unified token storage with automatic backend selection.
2//!
3//! Provides a single interface for token storage that can use either:
4//! - System keychain (default, most secure)
5//! - File-based storage (fallback)
6//!
7//! The backend is selected based on user configuration, with automatic
8//! fallback to file storage if keychain is unavailable.
9
10use thiserror::Error;
11use tracing::{debug, warn};
12
13use super::config::TokenStorageBackend;
14use super::keyring::{KeyringError, KeyringStore};
15use super::token_store::{TokenStore, TokenStoreError};
16use crate::oauth::token::Token;
17
18/// Errors that can occur with unified token storage.
19#[derive(Debug, Error)]
20pub enum UnifiedTokenError {
21    #[error("File storage error: {0}")]
22    File(#[from] TokenStoreError),
23
24    #[error("Keyring error: {0}")]
25    Keyring(#[from] KeyringError),
26
27    #[error("Token not found")]
28    NotFound,
29}
30
31/// Unified token storage that abstracts over keyring and file backends.
32pub struct UnifiedTokenStore {
33    backend: TokenStorageBackend,
34    keyring: Option<KeyringStore>,
35    file: TokenStore,
36}
37
38impl UnifiedTokenStore {
39    /// Create a new unified token store with the specified backend preference.
40    ///
41    /// If keyring is requested but unavailable, falls back to file storage.
42    pub fn new(preferred_backend: TokenStorageBackend) -> Result<Self, UnifiedTokenError> {
43        let file = TokenStore::new()?;
44
45        let (backend, keyring) = match preferred_backend {
46            TokenStorageBackend::Keyring => match KeyringStore::new() {
47                Ok(store) => {
48                    debug!("Using keyring for token storage");
49                    (TokenStorageBackend::Keyring, Some(store))
50                }
51                Err(e) => {
52                    warn!("Keyring unavailable, falling back to file storage: {}", e);
53                    (TokenStorageBackend::File, None)
54                }
55            },
56            TokenStorageBackend::File => {
57                debug!("Using file for token storage (configured)");
58                (TokenStorageBackend::File, None)
59            }
60        };
61
62        Ok(Self {
63            backend,
64            keyring,
65            file,
66        })
67    }
68
69    /// Save a token using the active backend.
70    pub fn save(&self, token: &Token) -> Result<(), UnifiedTokenError> {
71        match self.backend {
72            TokenStorageBackend::Keyring => {
73                if let Some(ref keyring) = self.keyring {
74                    keyring.save(token)?;
75                    debug!("Token saved to keyring");
76                    Ok(())
77                } else {
78                    // Fallback to file if keyring not available
79                    self.file.save(token)?;
80                    debug!("Token saved to file (keyring fallback)");
81                    Ok(())
82                }
83            }
84            TokenStorageBackend::File => {
85                self.file.save(token)?;
86                debug!("Token saved to file");
87                Ok(())
88            }
89        }
90    }
91
92    /// Load a token from the active backend.
93    ///
94    /// For keyring backend, falls back to file if keyring is empty (migration support).
95    pub fn load(&self) -> Result<Token, UnifiedTokenError> {
96        match self.backend {
97            TokenStorageBackend::Keyring => {
98                if let Some(ref keyring) = self.keyring {
99                    match keyring.load() {
100                        Ok(token) => {
101                            debug!("Token loaded from keyring");
102                            Ok(token)
103                        }
104                        Err(KeyringError::NotFound) => {
105                            // Try file as fallback (migration from file to keyring)
106                            debug!("Token not in keyring, checking file for migration");
107                            match self.file.load() {
108                                Ok(token) => {
109                                    debug!("Found token in file, migrating to keyring");
110                                    // Migrate to keyring
111                                    if keyring.save(&token).is_ok() {
112                                        // Delete from file after successful migration
113                                        let _ = self.file.delete();
114                                        debug!("Token migrated from file to keyring");
115                                    }
116                                    Ok(token)
117                                }
118                                Err(TokenStoreError::NotFound) => Err(UnifiedTokenError::NotFound),
119                                Err(e) => Err(UnifiedTokenError::File(e)),
120                            }
121                        }
122                        Err(e) => Err(UnifiedTokenError::Keyring(e)),
123                    }
124                } else {
125                    // Keyring wasn't available, use file
126                    self.file.load().map_err(|e| match e {
127                        TokenStoreError::NotFound => UnifiedTokenError::NotFound,
128                        other => UnifiedTokenError::File(other),
129                    })
130                }
131            }
132            TokenStorageBackend::File => self.file.load().map_err(|e| match e {
133                TokenStoreError::NotFound => UnifiedTokenError::NotFound,
134                other => UnifiedTokenError::File(other),
135            }),
136        }
137    }
138
139    /// Delete the token from storage.
140    ///
141    /// Attempts to delete from both backends to ensure clean removal.
142    pub fn delete(&self) -> Result<(), UnifiedTokenError> {
143        // Delete from keyring if available
144        if let Some(ref keyring) = self.keyring {
145            keyring.delete()?;
146            debug!("Token deleted from keyring");
147        }
148
149        // Also delete from file (cleanup any legacy tokens)
150        self.file.delete()?;
151        debug!("Token deleted from file");
152
153        Ok(())
154    }
155
156    /// Check if a token exists in storage.
157    pub fn exists(&self) -> bool {
158        match self.backend {
159            TokenStorageBackend::Keyring => {
160                if let Some(ref keyring) = self.keyring {
161                    keyring.exists() || self.file.exists()
162                } else {
163                    self.file.exists()
164                }
165            }
166            TokenStorageBackend::File => self.file.exists(),
167        }
168    }
169
170    /// Get the active backend type.
171    pub fn backend(&self) -> TokenStorageBackend {
172        self.backend
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn unified_token_error_display() {
182        let err = UnifiedTokenError::NotFound;
183        let display = format!("{}", err);
184        assert!(display.contains("not found"));
185    }
186
187    #[test]
188    fn file_backend_creates_successfully() {
189        let store = UnifiedTokenStore::new(TokenStorageBackend::File);
190        assert!(store.is_ok());
191        assert_eq!(store.unwrap().backend(), TokenStorageBackend::File);
192    }
193
194    #[test]
195    fn exists_returns_false_when_empty() {
196        let store = UnifiedTokenStore::new(TokenStorageBackend::File).unwrap();
197        // Note: This may return true if there's already a token file
198        // We just verify it doesn't panic
199        let _ = store.exists();
200    }
201}