1use config::{Config, ConfigError, Environment, File};
7use one_or_many::OneOrMany;
8use serde::Deserialize;
9
10use std::{path::PathBuf, str::FromStr};
11
12use mecomp_storage::util::MetadataConflictResolution;
13
14pub static DEFAULT_CONFIG: &str = include_str!("../Mecomp.toml");
15
16#[derive(Clone, Debug, Deserialize, Default, PartialEq, Eq)]
17pub struct Settings {
18 #[serde(default)]
20 pub daemon: DaemonSettings,
21 #[serde(default)]
23 pub reclustering: ReclusterSettings,
24 #[serde(default)]
26 pub tui: TuiSettings,
27}
28
29impl Settings {
30 #[inline]
45 pub fn init(
46 config: PathBuf,
47 port: Option<u16>,
48 log_level: Option<log::LevelFilter>,
49 ) -> Result<Self, ConfigError> {
50 let s = Config::builder()
51 .add_source(File::from(config))
52 .add_source(Environment::with_prefix("MECOMP"))
53 .build()?;
54
55 let mut settings: Self = s.try_deserialize()?;
56
57 for path in &mut settings.daemon.library_paths {
58 *path = shellexpand::tilde(&path.to_string_lossy())
59 .into_owned()
60 .into();
61 }
62
63 if let Some(port) = port {
64 settings.daemon.rpc_port = port;
65 }
66
67 if let Some(log_level) = log_level {
68 settings.daemon.log_level = log_level;
69 }
70
71 Ok(settings)
72 }
73
74 #[inline]
83 pub fn get_config_path() -> Result<PathBuf, std::io::Error> {
84 match crate::get_config_dir() {
85 Ok(config_dir) => {
86 if !config_dir.exists() {
88 std::fs::create_dir_all(&config_dir)?;
89 }
90 let config_file = config_dir.join("Mecomp.toml");
91
92 if !config_file.exists() {
93 std::fs::write(&config_file, DEFAULT_CONFIG)?;
94 }
95
96 Ok(config_file)
97 }
98 Err(e) => {
99 eprintln!("Error: {e}");
100 Err(std::io::Error::new(
101 std::io::ErrorKind::NotFound,
102 "Unable to find the config directory for mecomp.",
103 ))
104 }
105 }
106 }
107}
108
109#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
110pub struct DaemonSettings {
111 #[serde(default = "default_port")]
114 pub rpc_port: u16,
115 #[serde(default = "default_library_paths")]
117 pub library_paths: Box<[PathBuf]>,
118 #[serde(default, deserialize_with = "de_artist_separator")]
132 pub artist_separator: OneOrMany<String>,
133 #[serde(default)]
151 pub protected_artist_names: OneOrMany<String>,
152 #[serde(default)]
153 pub genre_separator: Option<String>,
154 #[serde(default)]
158 pub conflict_resolution: MetadataConflictResolution,
159 #[serde(default = "default_log_level")]
162 #[serde(deserialize_with = "de_log_level")]
163 pub log_level: log::LevelFilter,
164}
165
166fn de_artist_separator<'de, D>(deserializer: D) -> Result<OneOrMany<String>, D::Error>
167where
168 D: serde::Deserializer<'de>,
169{
170 let v = OneOrMany::<String>::deserialize(deserializer)?
171 .into_iter()
172 .filter(|s| !s.is_empty())
173 .collect::<OneOrMany<String>>();
174 if v.is_empty() {
175 Ok(OneOrMany::None)
176 } else {
177 Ok(v)
178 }
179}
180
181fn de_log_level<'de, D>(deserializer: D) -> Result<log::LevelFilter, D::Error>
182where
183 D: serde::Deserializer<'de>,
184{
185 let s = String::deserialize(deserializer)?;
186 Ok(log::LevelFilter::from_str(&s).unwrap_or_else(|_| default_log_level()))
187}
188
189const fn default_port() -> u16 {
190 6600
191}
192
193fn default_library_paths() -> Box<[PathBuf]> {
194 vec![shellexpand::tilde("~/Music/").into_owned().into()].into_boxed_slice()
195}
196
197const fn default_log_level() -> log::LevelFilter {
198 log::LevelFilter::Info
199}
200
201impl Default for DaemonSettings {
202 #[inline]
203 fn default() -> Self {
204 Self {
205 rpc_port: default_port(),
206 library_paths: default_library_paths(),
207 artist_separator: OneOrMany::None,
208 protected_artist_names: OneOrMany::None,
209 genre_separator: None,
210 conflict_resolution: MetadataConflictResolution::Overwrite,
211 log_level: default_log_level(),
212 }
213 }
214}
215
216#[derive(Clone, Copy, Debug, Deserialize, Default, PartialEq, Eq)]
217#[serde(rename_all = "lowercase")]
218pub enum ClusterAlgorithm {
219 KMeans,
220 #[default]
221 GMM,
222}
223
224#[cfg(feature = "analysis")]
225impl From<ClusterAlgorithm> for mecomp_analysis::clustering::ClusteringMethod {
226 #[inline]
227 fn from(algo: ClusterAlgorithm) -> Self {
228 match algo {
229 ClusterAlgorithm::KMeans => Self::KMeans,
230 ClusterAlgorithm::GMM => Self::GaussianMixtureModel,
231 }
232 }
233}
234
235#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
236#[serde(rename_all = "lowercase")]
237pub enum ProjectionMethod {
238 #[default]
239 None,
240 TSne,
241 Pca,
242}
243
244#[cfg(feature = "analysis")]
245impl From<ProjectionMethod> for mecomp_analysis::clustering::ProjectionMethod {
246 #[inline]
247 fn from(proj: ProjectionMethod) -> Self {
248 match proj {
249 ProjectionMethod::None => Self::None,
250 ProjectionMethod::TSne => Self::TSne,
251 ProjectionMethod::Pca => Self::Pca,
252 }
253 }
254}
255
256#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
257pub struct ReclusterSettings {
258 #[serde(default = "default_gap_statistic_reference_datasets")]
264 pub gap_statistic_reference_datasets: u32,
265 #[serde(default = "default_max_clusters")]
270 pub max_clusters: usize,
271 #[serde(default)]
274 pub algorithm: ClusterAlgorithm,
275 #[serde(default)]
279 pub projection_method: ProjectionMethod,
280}
281
282const fn default_gap_statistic_reference_datasets() -> u32 {
283 50
284}
285
286const fn default_max_clusters() -> usize {
287 #[cfg(debug_assertions)]
288 return 16;
289 #[cfg(not(debug_assertions))]
290 return 24;
291}
292
293impl Default for ReclusterSettings {
294 #[inline]
295 fn default() -> Self {
296 Self {
297 gap_statistic_reference_datasets: default_gap_statistic_reference_datasets(),
298 max_clusters: default_max_clusters(),
299 algorithm: ClusterAlgorithm::default(),
300 projection_method: ProjectionMethod::default(),
301 }
302 }
303}
304
305#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
306pub struct TuiSettings {
307 #[serde(default = "default_radio_count")]
310 pub radio_count: u32,
311 #[serde(default)]
320 pub colors: TuiColorScheme,
321}
322
323const fn default_radio_count() -> u32 {
324 20
325}
326
327impl Default for TuiSettings {
328 #[inline]
329 fn default() -> Self {
330 Self {
331 radio_count: default_radio_count(),
332 colors: TuiColorScheme::default(),
333 }
334 }
335}
336
337#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Default)]
338pub struct TuiColorScheme {
339 pub app_border: Option<String>,
341 pub app_border_text: Option<String>,
342 pub border_unfocused: Option<String>,
344 pub border_focused: Option<String>,
345 pub popup_border: Option<String>,
347 pub text_normal: Option<String>,
349 pub text_highlight: Option<String>,
350 pub text_highlight_alt: Option<String>,
351 pub gauge_filled: Option<String>,
353 pub gauge_unfilled: Option<String>,
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 use pretty_assertions::assert_eq;
361 use rstest::rstest;
362
363 #[derive(Debug, PartialEq, Eq, Deserialize)]
364 #[allow(dead_code)]
365 #[serde(transparent)]
366 struct ArtistSeparatorTest {
367 #[serde(deserialize_with = "de_artist_separator")]
368 artist_separator: OneOrMany<String>,
369 }
370
371 #[rstest]
372 #[case(Vec::<String>::new())]
373 #[case("")]
374 fn test_de_artist_separator_empty<'de, D>(#[case] input: D)
375 where
376 D: serde::de::IntoDeserializer<'de>,
377 {
378 let deserializer = input.into_deserializer();
379 let result: Result<OneOrMany<String>, _> = de_artist_separator(deserializer);
380 assert!(result.is_ok());
381 assert!(result.unwrap().is_empty());
382 }
383
384 #[rstest]
385 #[case(vec![" & "], String::from(" & ").into())]
386 #[case(" & ", String::from(" & ").into())]
387 #[case(vec![" & ", "; "], vec![String::from(" & "), String::from("; ")].into())]
388 #[case(vec!["", " & ", "", "; "], vec![String::from(" & "), String::from("; ")].into())]
389 fn test_de_artist_separator<'de, D>(#[case] input: D, #[case] expected: OneOrMany<String>)
390 where
391 D: serde::de::IntoDeserializer<'de>,
392 {
393 let deserializer = input.into_deserializer();
394 let result: Result<OneOrMany<String>, _> = de_artist_separator(deserializer);
395 assert!(result.is_ok());
396 assert_eq!(result.unwrap(), expected);
397 }
398
399 #[test]
400 fn test_init_config() {
401 let temp_dir = tempfile::tempdir().unwrap();
402 let config_path = temp_dir.path().join("config.toml");
403 std::fs::write(
404 &config_path,
405 r#"
406[daemon]
407rpc_port = 6600
408library_paths = ["/Music"]
409artist_separator = ["; "]
410genre_separator = ", "
411conflict_resolution = "overwrite"
412log_level = "debug"
413
414[reclustering]
415gap_statistic_reference_datasets = 50
416max_clusters = 24
417algorithm = "gmm"
418
419[tui]
420radio_count = 21
421[tui.colors]
422app_border = "PINK_900"
423app_border_text = "PINK_300"
424border_unfocused = "RED_900"
425border_focused = "RED_200"
426popup_border = "LIGHT_BLUE_500"
427text_normal = "WHITE"
428text_highlight = "RED_600"
429text_highlight_alt = "RED_200"
430gauge_filled = "WHITE"
431gauge_unfilled = "BLACK"
432 "#,
433 )
434 .unwrap();
435
436 let expected = Settings {
437 daemon: DaemonSettings {
438 rpc_port: 6600,
439 library_paths: ["/Music".into()].into(),
440 artist_separator: vec!["; ".into()].into(),
441 protected_artist_names: OneOrMany::None,
442 genre_separator: Some(", ".into()),
443 conflict_resolution: MetadataConflictResolution::Overwrite,
444 log_level: log::LevelFilter::Debug,
445 },
446 reclustering: ReclusterSettings {
447 gap_statistic_reference_datasets: 50,
448 max_clusters: 24,
449 algorithm: ClusterAlgorithm::GMM,
450 projection_method: ProjectionMethod::None,
451 },
452 tui: TuiSettings {
453 radio_count: 21,
454 colors: TuiColorScheme {
455 app_border: Some("PINK_900".into()),
456 app_border_text: Some("PINK_300".into()),
457 border_unfocused: Some("RED_900".into()),
458 border_focused: Some("RED_200".into()),
459 popup_border: Some("LIGHT_BLUE_500".into()),
460 text_normal: Some("WHITE".into()),
461 text_highlight: Some("RED_600".into()),
462 text_highlight_alt: Some("RED_200".into()),
463 gauge_filled: Some("WHITE".into()),
464 gauge_unfilled: Some("BLACK".into()),
465 },
466 },
467 };
468
469 let settings = Settings::init(config_path, None, None).unwrap();
470
471 assert_eq!(settings, expected);
472 }
473
474 #[test]
475 fn test_tui_colors_unset() {
476 let temp_dir = tempfile::tempdir().unwrap();
477 let config_path = temp_dir.path().join("config.toml");
478 std::fs::write(
479 &config_path,
480 r#"
481[daemon]
482rpc_port = 6600
483library_paths = ["/Music"]
484artist_separator = ["; "]
485protected_artist_names = ["Foo & Bar"]
486genre_separator = ", "
487conflict_resolution = "overwrite"
488log_level = "debug"
489
490[reclustering]
491gap_statistic_reference_datasets = 50
492max_clusters = 24
493algorithm = "gmm"
494
495[tui]
496radio_count = 21
497 "#,
498 )
499 .unwrap();
500
501 let expected = Settings {
502 daemon: DaemonSettings {
503 rpc_port: 6600,
504 library_paths: ["/Music".into()].into(),
505 artist_separator: vec!["; ".into()].into(),
506 protected_artist_names: "Foo & Bar".to_string().into(),
507 genre_separator: Some(", ".into()),
508 conflict_resolution: MetadataConflictResolution::Overwrite,
509 log_level: log::LevelFilter::Debug,
510 },
511 reclustering: ReclusterSettings {
512 gap_statistic_reference_datasets: 50,
513 max_clusters: 24,
514 algorithm: ClusterAlgorithm::GMM,
515 projection_method: ProjectionMethod::None,
516 },
517 tui: TuiSettings {
518 radio_count: 21,
519 colors: TuiColorScheme::default(),
520 },
521 };
522
523 let settings = Settings::init(config_path, None, None).unwrap();
524
525 assert_eq!(settings, expected);
526 }
527
528 #[test]
529 fn test_artist_names_to_not_split() {
530 let temp_dir = tempfile::tempdir().unwrap();
531 let config_path = temp_dir.path().join("config.toml");
532 std::fs::write(
533 &config_path,
534 r#"
535[daemon]
536rpc_port = 6600
537library_paths = ["/Music"]
538artist_separator = ["; "]
539protected_artist_names = ["Foo & Bar"]
540genre_separator = ", "
541conflict_resolution = "overwrite"
542log_level = "debug"
543
544[reclustering]
545gap_statistic_reference_datasets = 50
546max_clusters = 24
547algorithm = "gmm"
548
549[tui]
550radio_count = 21
551 "#,
552 )
553 .unwrap();
554
555 let expected = Settings {
556 daemon: DaemonSettings {
557 rpc_port: 6600,
558 library_paths: ["/Music".into()].into(),
559 artist_separator: vec!["; ".into()].into(),
560 protected_artist_names: "Foo & Bar".to_string().into(),
561 genre_separator: Some(", ".into()),
562 conflict_resolution: MetadataConflictResolution::Overwrite,
563 log_level: log::LevelFilter::Debug,
564 },
565 reclustering: ReclusterSettings {
566 gap_statistic_reference_datasets: 50,
567 max_clusters: 24,
568 algorithm: ClusterAlgorithm::GMM,
569 projection_method: ProjectionMethod::None,
570 },
571 tui: TuiSettings {
572 radio_count: 21,
573 colors: TuiColorScheme::default(),
574 },
575 };
576
577 let settings = Settings::init(config_path, None, None).unwrap();
578
579 assert_eq!(settings, expected);
580 }
581
582 #[test]
583 fn test_default_config_works() {
584 let temp_dir = tempfile::tempdir().unwrap();
585 let config_path = temp_dir.path().join("config.toml");
586 std::fs::write(&config_path, DEFAULT_CONFIG).unwrap();
587
588 let settings = Settings::init(config_path, None, None);
589
590 assert!(settings.is_ok(), "Error: {:?}", settings.err());
591 }
592}