spotify_cli/storage/
config.rs

1//! Configuration file handling.
2//!
3//! Loads and parses the TOML configuration file from the user's config directory.
4
5use serde::{Deserialize, Serialize};
6use std::fs;
7use thiserror::Error;
8
9use super::paths;
10
11/// Errors that can occur when loading configuration.
12#[derive(Debug, Error)]
13pub enum ConfigError {
14    #[error("Config file not found at {0}")]
15    NotFound(String),
16
17    #[error("Failed to read config: {0}")]
18    Read(#[from] std::io::Error),
19
20    #[error("Failed to parse config: {0}")]
21    Parse(#[from] toml::de::Error),
22
23    #[error("Failed to serialize config: {0}")]
24    Serialize(#[from] toml::ser::Error),
25
26    #[error("Could not determine config path: {0}")]
27    Path(#[from] paths::PathError),
28
29    #[error("Missing required field: {0}")]
30    MissingField(String),
31}
32
33/// Spotify API credentials and core settings.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct SpotifyConfig {
36    /// Spotify Developer App client ID.
37    pub client_id: String,
38    /// Token storage backend (keyring or file)
39    #[serde(default)]
40    pub token_storage: TokenStorageBackend,
41}
42
43/// Fuzzy search scoring configuration.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct FuzzyConfig {
46    /// Score for exact name match
47    #[serde(default = "default_exact_match")]
48    pub exact_match: f64,
49    /// Score for name starts with query
50    #[serde(default = "default_starts_with")]
51    pub starts_with: f64,
52    /// Score for name contains query
53    #[serde(default = "default_contains")]
54    pub contains: f64,
55    /// Score for each word match
56    #[serde(default = "default_word_match")]
57    pub word_match: f64,
58    /// Minimum similarity threshold for Levenshtein matching (0.0-1.0)
59    #[serde(default = "default_similarity_threshold")]
60    pub similarity_threshold: f64,
61    /// Weight multiplier for Levenshtein similarity bonus
62    #[serde(default = "default_similarity_weight")]
63    pub similarity_weight: f64,
64}
65
66fn default_exact_match() -> f64 {
67    100.0
68}
69fn default_starts_with() -> f64 {
70    50.0
71}
72fn default_contains() -> f64 {
73    30.0
74}
75fn default_word_match() -> f64 {
76    10.0
77}
78fn default_similarity_threshold() -> f64 {
79    0.6
80}
81fn default_similarity_weight() -> f64 {
82    20.0
83}
84
85impl Default for FuzzyConfig {
86    fn default() -> Self {
87        Self {
88            exact_match: default_exact_match(),
89            starts_with: default_starts_with(),
90            contains: default_contains(),
91            word_match: default_word_match(),
92            similarity_threshold: default_similarity_threshold(),
93            similarity_weight: default_similarity_weight(),
94        }
95    }
96}
97
98/// Search behavior configuration.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SearchConfig {
101    /// Include fuzzy scores in results
102    #[serde(default = "default_show_scores")]
103    pub show_scores: bool,
104    /// Sort results by fuzzy score (default: false, preserves Spotify's order)
105    #[serde(default = "default_sort_by_score")]
106    pub sort_by_score: bool,
107    /// Fuzzy matching configuration
108    #[serde(default)]
109    pub fuzzy: FuzzyConfig,
110}
111
112fn default_show_scores() -> bool {
113    true
114}
115fn default_sort_by_score() -> bool {
116    false
117}
118
119impl Default for SearchConfig {
120    fn default() -> Self {
121        Self {
122            show_scores: default_show_scores(),
123            sort_by_score: default_sort_by_score(),
124            fuzzy: FuzzyConfig::default(),
125        }
126    }
127}
128
129/// Token storage backend options.
130#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "lowercase")]
132pub enum TokenStorageBackend {
133    /// Use system keychain (default, most secure)
134    #[default]
135    Keyring,
136    /// Use file-based storage (fallback)
137    File,
138}
139
140/// Root configuration structure.
141///
142/// Loaded from `~/.config/spotify-cli/config.toml`.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct Config {
145    #[serde(rename = "spotify-cli")]
146    pub spotify_cli: SpotifyConfig,
147    #[serde(default)]
148    pub search: SearchConfig,
149}
150
151impl Config {
152    /// Load configuration from the default config file.
153    ///
154    /// Returns error if file doesn't exist or client_id is missing.
155    pub fn load() -> Result<Self, ConfigError> {
156        let path = paths::config_file()?;
157
158        if !path.exists() {
159            return Err(ConfigError::NotFound(path.display().to_string()));
160        }
161
162        let contents = fs::read_to_string(&path)?;
163        let config: Config = toml::from_str(&contents)?;
164
165        if config.spotify_cli.client_id.is_empty() {
166            return Err(ConfigError::MissingField("client_id".to_string()));
167        }
168
169        Ok(config)
170    }
171
172    pub fn client_id(&self) -> &str {
173        &self.spotify_cli.client_id
174    }
175
176    pub fn fuzzy(&self) -> &FuzzyConfig {
177        &self.search.fuzzy
178    }
179
180    pub fn show_scores(&self) -> bool {
181        self.search.show_scores
182    }
183
184    pub fn sort_by_score(&self) -> bool {
185        self.search.sort_by_score
186    }
187
188    pub fn token_storage(&self) -> TokenStorageBackend {
189        self.spotify_cli.token_storage
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn parse_valid_config() {
199        let toml = r#"
200[spotify-cli]
201client_id = "abc123"
202"#;
203
204        let config: Config = toml::from_str(toml).unwrap();
205        assert_eq!(config.client_id(), "abc123");
206    }
207
208    #[test]
209    fn missing_client_id_fails() {
210        let toml = r#"
211[spotify-cli]
212client_id = ""
213"#;
214
215        let config: Config = toml::from_str(toml).unwrap();
216        // The parse succeeds, but load() would fail with MissingField
217        assert!(config.client_id().is_empty());
218    }
219
220    #[test]
221    fn fuzzy_config_default_values() {
222        let fuzzy = FuzzyConfig::default();
223        assert_eq!(fuzzy.exact_match, 100.0);
224        assert_eq!(fuzzy.starts_with, 50.0);
225        assert_eq!(fuzzy.contains, 30.0);
226        assert_eq!(fuzzy.word_match, 10.0);
227        assert_eq!(fuzzy.similarity_threshold, 0.6);
228        assert_eq!(fuzzy.similarity_weight, 20.0);
229    }
230
231    #[test]
232    fn search_config_default_values() {
233        let search = SearchConfig::default();
234        assert!(search.show_scores);
235        assert!(!search.sort_by_score);
236    }
237
238    #[test]
239    fn config_with_search_settings() {
240        let toml = r#"
241[spotify-cli]
242client_id = "abc123"
243
244[search]
245show_scores = false
246sort_by_score = true
247"#;
248
249        let config: Config = toml::from_str(toml).unwrap();
250        assert!(!config.show_scores());
251        assert!(config.sort_by_score());
252    }
253
254    #[test]
255    fn config_with_fuzzy_settings() {
256        let toml = r#"
257[spotify-cli]
258client_id = "abc123"
259
260[search.fuzzy]
261exact_match = 200.0
262starts_with = 100.0
263"#;
264
265        let config: Config = toml::from_str(toml).unwrap();
266        assert_eq!(config.fuzzy().exact_match, 200.0);
267        assert_eq!(config.fuzzy().starts_with, 100.0);
268        // Defaults should still apply for unset fields
269        assert_eq!(config.fuzzy().contains, 30.0);
270    }
271
272    #[test]
273    fn config_defaults_when_search_section_missing() {
274        let toml = r#"
275[spotify-cli]
276client_id = "abc123"
277"#;
278
279        let config: Config = toml::from_str(toml).unwrap();
280        // Default search config values
281        assert!(config.show_scores());
282        assert!(!config.sort_by_score());
283    }
284
285    #[test]
286    fn config_error_display() {
287        let err = ConfigError::NotFound("/path/to/config".to_string());
288        assert!(err.to_string().contains("/path/to/config"));
289
290        let err = ConfigError::MissingField("client_id".to_string());
291        assert!(err.to_string().contains("client_id"));
292    }
293
294    #[test]
295    fn spotify_config_deserializes() {
296        let toml = r#"client_id = "test_client_id""#;
297        let config: SpotifyConfig = toml::from_str(toml).unwrap();
298        assert_eq!(config.client_id, "test_client_id");
299    }
300
301    #[test]
302    fn token_storage_defaults_to_keyring() {
303        let toml = r#"
304[spotify-cli]
305client_id = "abc123"
306"#;
307        let config: Config = toml::from_str(toml).unwrap();
308        assert_eq!(config.token_storage(), TokenStorageBackend::Keyring);
309    }
310
311    #[test]
312    fn token_storage_file_option() {
313        let toml = r#"
314[spotify-cli]
315client_id = "abc123"
316
317token_storage = "file"
318"#;
319        let config: Config = toml::from_str(toml).unwrap();
320        assert_eq!(config.token_storage(), TokenStorageBackend::File);
321    }
322
323    #[test]
324    fn token_storage_keyring_option() {
325        let toml = r#"
326[spotify-cli]
327client_id = "abc123"
328
329token_storage = "keyring"
330"#;
331        let config: Config = toml::from_str(toml).unwrap();
332        assert_eq!(config.token_storage(), TokenStorageBackend::Keyring);
333    }
334
335    #[test]
336    fn token_storage_backend_default() {
337        let backend = TokenStorageBackend::default();
338        assert_eq!(backend, TokenStorageBackend::Keyring);
339    }
340}