1use std::collections::HashMap;
7use std::fmt;
8use std::path::Path;
9use std::str::FromStr;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
17#[serde(rename_all = "lowercase")]
18pub enum AudioFormat {
19 Mp3,
20 #[default]
21 Flac,
22 Wav,
23}
24
25impl FromStr for AudioFormat {
26 type Err = Error;
27
28 fn from_str(s: &str) -> Result<Self> {
29 match s.to_ascii_lowercase().as_str() {
30 "mp3" => Ok(Self::Mp3),
31 "flac" => Ok(Self::Flac),
32 "wav" => Ok(Self::Wav),
33 other => Err(Error::Config(format!("unknown format '{other}'"))),
34 }
35 }
36}
37
38impl fmt::Display for AudioFormat {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 match self {
41 Self::Mp3 => f.write_str("mp3"),
42 Self::Flac => f.write_str("flac"),
43 Self::Wav => f.write_str("wav"),
44 }
45 }
46}
47
48#[derive(Debug, Clone, Default, Deserialize)]
50pub struct Defaults {
51 pub format: Option<AudioFormat>,
52 pub concurrency: Option<u32>,
53 pub retries: Option<u32>,
54 pub min_newest: Option<u32>,
55 pub animated_covers: Option<bool>,
56 pub details_sidecar: Option<bool>,
57 pub lyrics_sidecar: Option<bool>,
58 pub lrc_sidecar: Option<bool>,
59}
60
61#[derive(Debug, Clone, Default, Deserialize)]
63pub struct SourceConfig {
64 pub format: Option<AudioFormat>,
65 pub concurrency: Option<u32>,
66 pub retries: Option<u32>,
67 pub min_newest: Option<u32>,
68 pub animated_covers: Option<bool>,
69 pub details_sidecar: Option<bool>,
70 pub lyrics_sidecar: Option<bool>,
71 pub lrc_sidecar: Option<bool>,
72}
73
74#[derive(Debug, Clone, Default, Deserialize)]
76pub struct AccountConfig {
77 pub token: Option<String>,
78 pub root: Option<String>,
79 pub account_id: Option<String>,
83 pub format: Option<AudioFormat>,
84 pub concurrency: Option<u32>,
85 pub retries: Option<u32>,
86 pub min_newest: Option<u32>,
87 pub animated_covers: Option<bool>,
88 pub details_sidecar: Option<bool>,
89 pub lyrics_sidecar: Option<bool>,
90 pub lrc_sidecar: Option<bool>,
91 #[serde(default)]
92 pub sources: HashMap<String, SourceConfig>,
93}
94
95#[derive(Debug, Clone, Default, Deserialize)]
97pub struct Config {
98 #[serde(default)]
99 pub defaults: Defaults,
100 #[serde(default)]
101 pub accounts: HashMap<String, AccountConfig>,
102}
103
104impl Config {
105 pub fn from_toml(toml_str: &str) -> Result<Self> {
111 let config: Self = toml::from_str(toml_str).map_err(|e| {
112 let raw = e.to_string();
115 let msg = raw
116 .lines()
117 .filter(|l| !l.contains(" | "))
118 .collect::<Vec<_>>()
119 .join("\n")
120 .trim()
121 .to_owned();
122 Error::Config(if msg.is_empty() {
123 "parse error".into()
124 } else {
125 msg
126 })
127 })?;
128 config.validate()?;
129 Ok(config)
130 }
131
132 fn validate(&self) -> Result<()> {
133 let roots: Vec<(&str, &str)> = self
134 .accounts
135 .iter()
136 .filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
137 .collect();
138
139 for (i, (label_a, root_a)) in roots.iter().enumerate() {
140 for (label_b, root_b) in roots.iter().skip(i + 1) {
141 let a = Path::new(root_a);
142 let b = Path::new(root_b);
143 if a.starts_with(b) || b.starts_with(a) {
144 return Err(Error::Config(format!(
145 "account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
146 )));
147 }
148 }
149 }
150
151 let mut prefix_seen: HashMap<String, &str> = HashMap::new();
152 for label in self.accounts.keys() {
153 let prefix = label_to_env(label);
154 if let Some(other) = prefix_seen.get(&prefix) {
155 return Err(Error::Config(format!(
156 "accounts '{label}' and '{other}' share env prefix '{prefix}'"
157 )));
158 }
159 prefix_seen.insert(prefix, label.as_str());
160 }
161
162 Ok(())
163 }
164
165 pub fn resolve(
171 &self,
172 account: &str,
173 source: Option<&str>,
174 env: &HashMap<String, String>,
175 flags: &FlagOverrides,
176 ) -> Result<EffectiveSettings> {
177 let acc = self
178 .accounts
179 .get(account)
180 .ok_or_else(|| Error::Config(format!("account '{account}' not found")))?;
181
182 let src = source.and_then(|s| acc.sources.get(s));
183 let label_env = label_to_env(account);
184
185 let env_val = |suffix: &str| -> Option<&str> {
187 env.get(&format!("SUNO_{label_env}_{suffix}"))
188 .or_else(|| env.get(&format!("SUNO_{suffix}")))
189 .map(String::as_str)
190 };
191
192 let format_from_env = env_val("FORMAT")
193 .map(str::parse::<AudioFormat>)
194 .transpose()?;
195
196 let format = flags
197 .format
198 .or(format_from_env)
199 .or_else(|| src.and_then(|s| s.format))
200 .or(acc.format)
201 .or(self.defaults.format)
202 .unwrap_or(AudioFormat::Flac);
203
204 let concurrency = resolve_u32(
205 flags.concurrency,
206 env_val("CONCURRENCY"),
207 src.and_then(|s| s.concurrency),
208 acc.concurrency,
209 self.defaults.concurrency,
210 4,
211 "CONCURRENCY",
212 )?;
213
214 let retries = resolve_u32(
215 flags.retries,
216 env_val("RETRIES"),
217 src.and_then(|s| s.retries),
218 acc.retries,
219 self.defaults.retries,
220 3,
221 "RETRIES",
222 )?;
223
224 let min_newest = resolve_u32(
225 flags.min_newest,
226 env_val("MIN_NEWEST"),
227 src.and_then(|s| s.min_newest),
228 acc.min_newest,
229 self.defaults.min_newest,
230 1,
231 "MIN_NEWEST",
232 )?;
233
234 let animated_covers = resolve_bool(
235 flags.animated_covers,
236 env_val("ANIMATED_COVERS"),
237 src.and_then(|s| s.animated_covers),
238 acc.animated_covers,
239 self.defaults.animated_covers,
240 false,
241 "ANIMATED_COVERS",
242 )?;
243
244 let details_sidecar = resolve_bool(
245 flags.details_sidecar,
246 env_val("DETAILS_SIDECAR"),
247 src.and_then(|s| s.details_sidecar),
248 acc.details_sidecar,
249 self.defaults.details_sidecar,
250 false,
251 "DETAILS_SIDECAR",
252 )?;
253
254 let lyrics_sidecar = resolve_bool(
255 flags.lyrics_sidecar,
256 env_val("LYRICS_SIDECAR"),
257 src.and_then(|s| s.lyrics_sidecar),
258 acc.lyrics_sidecar,
259 self.defaults.lyrics_sidecar,
260 false,
261 "LYRICS_SIDECAR",
262 )?;
263
264 let lrc_sidecar = resolve_bool(
265 flags.lrc_sidecar,
266 env_val("LRC_SIDECAR"),
267 src.and_then(|s| s.lrc_sidecar),
268 acc.lrc_sidecar,
269 self.defaults.lrc_sidecar,
270 false,
271 "LRC_SIDECAR",
272 )?;
273
274 let token = flags
275 .token
276 .clone()
277 .or_else(|| env.get(&format!("SUNO_{label_env}_TOKEN")).cloned())
278 .or_else(|| env.get("SUNO_TOKEN").cloned())
279 .or_else(|| acc.token.clone());
280
281 Ok(EffectiveSettings {
282 token,
283 account_id: acc.account_id.clone(),
284 format,
285 concurrency,
286 retries,
287 min_newest,
288 animated_covers,
289 details_sidecar,
290 lyrics_sidecar,
291 lrc_sidecar,
292 })
293 }
294}
295
296fn resolve_u32(
297 flag: Option<u32>,
298 env_str: Option<&str>,
299 src: Option<u32>,
300 acc: Option<u32>,
301 defaults: Option<u32>,
302 compiled: u32,
303 name: &str,
304) -> Result<u32> {
305 if let Some(v) = flag {
306 return Ok(v);
307 }
308 if let Some(s) = env_str {
309 return s
310 .parse()
311 .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
312 }
313 Ok(src.or(acc).or(defaults).unwrap_or(compiled))
314}
315
316fn resolve_bool(
317 flag: Option<bool>,
318 env_str: Option<&str>,
319 src: Option<bool>,
320 acc: Option<bool>,
321 defaults: Option<bool>,
322 compiled: bool,
323 name: &str,
324) -> Result<bool> {
325 if let Some(v) = flag {
326 return Ok(v);
327 }
328 if let Some(s) = env_str {
329 return s
330 .parse()
331 .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
332 }
333 Ok(src.or(acc).or(defaults).unwrap_or(compiled))
334}
335
336fn label_to_env(label: &str) -> String {
340 label.to_ascii_uppercase().replace('-', "_")
341}
342
343#[derive(Debug, Default)]
346pub struct FlagOverrides {
347 pub token: Option<String>,
348 pub format: Option<AudioFormat>,
349 pub concurrency: Option<u32>,
350 pub retries: Option<u32>,
351 pub min_newest: Option<u32>,
352 pub animated_covers: Option<bool>,
353 pub details_sidecar: Option<bool>,
354 pub lyrics_sidecar: Option<bool>,
355 pub lrc_sidecar: Option<bool>,
356}
357
358#[derive(Debug, Clone, PartialEq)]
360pub struct EffectiveSettings {
361 pub token: Option<String>,
362 pub account_id: Option<String>,
364 pub format: AudioFormat,
365 pub concurrency: u32,
366 pub retries: u32,
367 pub min_newest: u32,
368 pub animated_covers: bool,
369 pub details_sidecar: bool,
370 pub lyrics_sidecar: bool,
371 pub lrc_sidecar: bool,
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 fn no_env() -> HashMap<String, String> {
379 HashMap::new()
380 }
381
382 fn no_flags() -> FlagOverrides {
383 FlagOverrides::default()
384 }
385
386 #[test]
387 fn parse_empty_toml() {
388 let cfg = Config::from_toml("").unwrap();
389 assert!(cfg.accounts.is_empty());
390 }
391
392 #[test]
393 fn parse_basic_account() {
394 let toml = r#"
395 [accounts.alice]
396 token = "tok"
397 root = "/music"
398 "#;
399 let cfg = Config::from_toml(toml).unwrap();
400 let acc = &cfg.accounts["alice"];
401 assert_eq!(acc.token.as_deref(), Some("tok"));
402 assert_eq!(acc.root.as_deref(), Some("/music"));
403 }
404
405 #[test]
406 fn account_id_parses_and_resolves() {
407 let toml = r#"
408 [accounts.alice]
409 token = "tok"
410 root = "/music"
411 account_id = "user_abc123"
412 "#;
413 let cfg = Config::from_toml(toml).unwrap();
414 assert_eq!(
415 cfg.accounts["alice"].account_id.as_deref(),
416 Some("user_abc123")
417 );
418 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
419 assert_eq!(eff.account_id.as_deref(), Some("user_abc123"));
420 }
421
422 #[test]
423 fn parse_defaults_section() {
424 let toml = r#"
425 [defaults]
426 format = "mp3"
427 concurrency = 8
428 retries = 5
429 min_newest = 2
430 animated_covers = true
431 "#;
432 let cfg = Config::from_toml(toml).unwrap();
433 assert_eq!(cfg.defaults.format, Some(AudioFormat::Mp3));
434 assert_eq!(cfg.defaults.concurrency, Some(8));
435 assert_eq!(cfg.defaults.retries, Some(5));
436 assert_eq!(cfg.defaults.min_newest, Some(2));
437 assert_eq!(cfg.defaults.animated_covers, Some(true));
438 }
439
440 #[test]
441 fn compiled_defaults_when_nothing_set() {
442 let toml = "[accounts.alice]\n";
443 let cfg = Config::from_toml(toml).unwrap();
444 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
445 assert_eq!(
446 eff,
447 EffectiveSettings {
448 token: None,
449 account_id: None,
450 format: AudioFormat::Flac,
451 concurrency: 4,
452 retries: 3,
453 min_newest: 1,
454 animated_covers: false,
455 details_sidecar: false,
456 lyrics_sidecar: false,
457 lrc_sidecar: false,
458 }
459 );
460 }
461
462 #[test]
463 fn file_defaults_override_compiled() {
464 let toml = r#"
465 [defaults]
466 format = "mp3"
467 concurrency = 8
468
469 [accounts.alice]
470 "#;
471 let cfg = Config::from_toml(toml).unwrap();
472 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
473 assert_eq!(eff.format, AudioFormat::Mp3);
474 assert_eq!(eff.concurrency, 8);
475 assert_eq!(eff.retries, 3); }
477
478 #[test]
479 fn account_settings_override_defaults() {
480 let toml = r#"
481 [defaults]
482 format = "mp3"
483
484 [accounts.alice]
485 format = "wav"
486 "#;
487 let cfg = Config::from_toml(toml).unwrap();
488 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
489 assert_eq!(eff.format, AudioFormat::Wav);
490 }
491
492 #[test]
493 fn per_source_overrides_account() {
494 let toml = r#"
495 [accounts.alice]
496 format = "flac"
497
498 [accounts.alice.sources.liked]
499 format = "mp3"
500 "#;
501 let cfg = Config::from_toml(toml).unwrap();
502 let eff = cfg
503 .resolve("alice", Some("liked"), &no_env(), &no_flags())
504 .unwrap();
505 assert_eq!(eff.format, AudioFormat::Mp3);
506 }
507
508 #[test]
509 fn unknown_source_falls_back_to_account() {
510 let toml = r#"
511 [accounts.alice]
512 format = "wav"
513 "#;
514 let cfg = Config::from_toml(toml).unwrap();
515 let eff = cfg
516 .resolve("alice", Some("nonexistent"), &no_env(), &no_flags())
517 .unwrap();
518 assert_eq!(eff.format, AudioFormat::Wav);
519 }
520
521 #[test]
522 fn global_env_overrides_file() {
523 let toml = r#"
524 [accounts.alice]
525 format = "flac"
526 "#;
527 let cfg = Config::from_toml(toml).unwrap();
528 let env: HashMap<String, String> =
529 [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
530 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
531 assert_eq!(eff.format, AudioFormat::Mp3);
532 }
533
534 #[test]
535 fn per_account_env_overrides_global_env() {
536 let toml = "[accounts.alice]\n";
537 let cfg = Config::from_toml(toml).unwrap();
538 let env: HashMap<String, String> = [
539 ("SUNO_FORMAT".into(), "mp3".into()),
540 ("SUNO_ALICE_FORMAT".into(), "wav".into()),
541 ]
542 .into_iter()
543 .collect();
544 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
545 assert_eq!(eff.format, AudioFormat::Wav);
546 }
547
548 #[test]
549 fn per_account_env_label_uppersnakedcase() {
550 let toml = "[accounts.my-lib]\n";
551 let cfg = Config::from_toml(toml).unwrap();
552 let env: HashMap<String, String> = [("SUNO_MY_LIB_FORMAT".into(), "wav".into())]
553 .into_iter()
554 .collect();
555 let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
556 assert_eq!(eff.format, AudioFormat::Wav);
557 }
558
559 #[test]
560 fn flag_overrides_env_and_file() {
561 let toml = r#"
562 [accounts.alice]
563 format = "flac"
564 "#;
565 let cfg = Config::from_toml(toml).unwrap();
566 let env: HashMap<String, String> =
567 [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
568 let flags = FlagOverrides {
569 format: Some(AudioFormat::Wav),
570 ..Default::default()
571 };
572 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
573 assert_eq!(eff.format, AudioFormat::Wav);
574 }
575
576 #[test]
577 fn token_precedence() {
578 let toml = r#"
579 [accounts.alice]
580 token = "file_tok"
581 "#;
582 let cfg = Config::from_toml(toml).unwrap();
583
584 let env: HashMap<String, String> = [("SUNO_TOKEN".into(), "env_tok".into())]
586 .into_iter()
587 .collect();
588 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
589 assert_eq!(eff.token.as_deref(), Some("env_tok"));
590
591 let flags = FlagOverrides {
593 token: Some("flag_tok".into()),
594 ..Default::default()
595 };
596 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
597 assert_eq!(eff.token.as_deref(), Some("flag_tok"));
598 }
599
600 #[test]
601 fn per_account_token_env_overrides_global() {
602 let toml = "[accounts.alice]\n";
603 let cfg = Config::from_toml(toml).unwrap();
604 let env: HashMap<String, String> = [
605 ("SUNO_TOKEN".into(), "global".into()),
606 ("SUNO_ALICE_TOKEN".into(), "per_account".into()),
607 ]
608 .into_iter()
609 .collect();
610 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
611 assert_eq!(eff.token.as_deref(), Some("per_account"));
612 }
613
614 #[test]
615 fn invalid_env_u32_errors() {
616 let toml = "[accounts.alice]\n";
617 let cfg = Config::from_toml(toml).unwrap();
618 let env: HashMap<String, String> = [("SUNO_CONCURRENCY".into(), "many".into())]
619 .into_iter()
620 .collect();
621 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
622 }
623
624 #[test]
625 fn animated_covers_defaults_off_and_follows_precedence() {
626 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
628 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
629 assert!(!eff.animated_covers);
630
631 let toml = r#"
633 [defaults]
634 animated_covers = true
635
636 [accounts.alice.sources.liked]
637 animated_covers = false
638 "#;
639 let cfg = Config::from_toml(toml).unwrap();
640
641 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
643 assert!(eff.animated_covers);
644
645 let eff = cfg
647 .resolve("alice", Some("liked"), &no_env(), &no_flags())
648 .unwrap();
649 assert!(!eff.animated_covers);
650
651 let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "true".into())]
653 .into_iter()
654 .collect();
655 let eff = cfg
656 .resolve("alice", Some("liked"), &env, &no_flags())
657 .unwrap();
658 assert!(eff.animated_covers);
659
660 let flags = FlagOverrides {
662 animated_covers: Some(false),
663 ..Default::default()
664 };
665 let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
666 assert!(!eff.animated_covers);
667 }
668
669 #[test]
670 fn text_sidecars_default_off_and_follow_precedence() {
671 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
673 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
674 assert!(!eff.details_sidecar);
675 assert!(!eff.lyrics_sidecar);
676
677 let toml = r#"
678 [defaults]
679 details_sidecar = true
680
681 [accounts.alice.sources.liked]
682 details_sidecar = false
683 "#;
684 let cfg = Config::from_toml(toml).unwrap();
685
686 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
688 assert!(eff.details_sidecar);
689 assert!(!eff.lyrics_sidecar);
690
691 let eff = cfg
693 .resolve("alice", Some("liked"), &no_env(), &no_flags())
694 .unwrap();
695 assert!(!eff.details_sidecar);
696
697 let env: HashMap<String, String> = [
699 ("SUNO_DETAILS_SIDECAR".into(), "true".into()),
700 ("SUNO_LYRICS_SIDECAR".into(), "true".into()),
701 ]
702 .into_iter()
703 .collect();
704 let eff = cfg
705 .resolve("alice", Some("liked"), &env, &no_flags())
706 .unwrap();
707 assert!(eff.details_sidecar);
708 assert!(eff.lyrics_sidecar);
709
710 let flags = FlagOverrides {
711 lyrics_sidecar: Some(false),
712 ..Default::default()
713 };
714 let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
715 assert!(eff.details_sidecar);
716 assert!(!eff.lyrics_sidecar);
717 }
718
719 #[test]
720 fn invalid_env_bool_errors() {
721 let toml = "[accounts.alice]\n";
722 let cfg = Config::from_toml(toml).unwrap();
723 let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "yes".into())]
724 .into_iter()
725 .collect();
726 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
727 }
728
729 #[test]
730 fn unknown_account_errors() {
731 let cfg = Config::from_toml("").unwrap();
732 assert!(cfg.resolve("nobody", None, &no_env(), &no_flags()).is_err());
733 }
734
735 #[test]
736 fn validation_nested_roots() {
737 let toml = r#"
738 [accounts.alice]
739 root = "/music"
740
741 [accounts.bob]
742 root = "/music/bob"
743 "#;
744 assert!(Config::from_toml(toml).is_err());
745 }
746
747 #[test]
748 fn validation_non_nested_roots_ok() {
749 let toml = r#"
750 [accounts.alice]
751 root = "/music/alice"
752
753 [accounts.bob]
754 root = "/music/bob"
755 "#;
756 assert!(Config::from_toml(toml).is_ok());
757 }
758
759 #[test]
760 fn invalid_toml_errors() {
761 assert!(Config::from_toml("not valid toml ][").is_err());
762 }
763
764 #[test]
765 fn duplicate_account_label_errors() {
766 let toml = "
768 [accounts.alice]
769 token = \"tok1\"
770
771 [accounts.alice]
772 token = \"tok2\"
773 ";
774 assert!(Config::from_toml(toml).is_err());
775 }
776
777 #[test]
778 fn parse_error_does_not_echo_token() {
779 let toml = "[accounts.alice]\ntoken = \"unterminated\n";
781 let err = Config::from_toml(toml).unwrap_err().to_string();
782 assert!(!err.contains("unterminated"), "error leaked token: {err}");
783 }
784
785 #[test]
786 fn validation_env_prefix_collision_errors() {
787 let toml = "
789 [accounts.my-lib]
790 [accounts.my_lib]
791 ";
792 assert!(Config::from_toml(toml).is_err());
793 }
794
795 #[test]
796 fn audio_format_display_roundtrip() {
797 for fmt in [AudioFormat::Mp3, AudioFormat::Flac, AudioFormat::Wav] {
798 let s = fmt.to_string();
799 assert_eq!(s.parse::<AudioFormat>().unwrap(), fmt);
800 }
801 }
802}