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