spotify_cli/storage/
config.rs1use serde::{Deserialize, Serialize};
6use std::fs;
7use thiserror::Error;
8
9use super::paths;
10
11#[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#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct SpotifyConfig {
36 pub client_id: String,
38 #[serde(default)]
40 pub token_storage: TokenStorageBackend,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct FuzzyConfig {
46 #[serde(default = "default_exact_match")]
48 pub exact_match: f64,
49 #[serde(default = "default_starts_with")]
51 pub starts_with: f64,
52 #[serde(default = "default_contains")]
54 pub contains: f64,
55 #[serde(default = "default_word_match")]
57 pub word_match: f64,
58 #[serde(default = "default_similarity_threshold")]
60 pub similarity_threshold: f64,
61 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SearchConfig {
101 #[serde(default = "default_show_scores")]
103 pub show_scores: bool,
104 #[serde(default = "default_sort_by_score")]
106 pub sort_by_score: bool,
107 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "lowercase")]
132pub enum TokenStorageBackend {
133 #[default]
135 Keyring,
136 File,
138}
139
140#[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 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 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 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 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}