use std::path::PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
use suno_core::{AudioFormat, CharacterSet};
#[derive(Parser, Debug)]
#[command(name = "suno", version, about, long_about = None)]
pub struct Cli {
#[command(flatten)]
pub global: GlobalArgs,
#[command(subcommand)]
pub command: Command,
}
#[derive(Args, Debug, Clone, Default)]
pub struct GlobalArgs {
#[arg(long, global = true, env = "SUNO_ACCOUNT", value_name = "LABEL")]
pub account: Option<String>,
#[arg(long, global = true, conflicts_with = "account")]
pub all: bool,
#[arg(long, global = true, env = "SUNO_CONFIG", value_name = "PATH")]
pub config: Option<PathBuf>,
#[arg(short = 'n', long, global = true, env = "SUNO_DRY_RUN")]
pub dry_run: bool,
#[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
pub verbose: u8,
#[arg(short = 'q', long, global = true, action = clap::ArgAction::Count)]
pub quiet: u8,
#[arg(short = 'y', long, global = true, env = "SUNO_YES")]
pub yes: bool,
#[arg(long, global = true, hide_env_values = true, value_name = "TOKEN")]
pub token: Option<String>,
}
impl GlobalArgs {
pub fn verbosity(&self) -> i8 {
i8::try_from(self.verbose).unwrap_or(i8::MAX) - i8::try_from(self.quiet).unwrap_or(i8::MAX)
}
}
#[derive(Subcommand, Debug)]
pub enum Command {
Sync(SyncArgs),
Copy(SyncArgs),
Check(CheckArgs),
Ls(LsArgs),
Lsjson(LsArgs),
Fetch(FetchArgs),
Config(ConfigArgs),
Auth(AuthArgs),
Version,
Completions(CompletionsArgs),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "lower")]
pub enum AudioFmt {
Mp3,
Flac,
Wav,
}
impl From<AudioFmt> for AudioFormat {
fn from(value: AudioFmt) -> Self {
match value {
AudioFmt::Mp3 => AudioFormat::Mp3,
AudioFmt::Flac => AudioFormat::Flac,
AudioFmt::Wav => AudioFormat::Wav,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "lower")]
pub enum Charset {
Unicode,
Ascii,
}
impl From<Charset> for CharacterSet {
fn from(value: Charset) -> Self {
match value {
Charset::Unicode => CharacterSet::Unicode,
Charset::Ascii => CharacterSet::Ascii,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
#[value(rename_all = "lower")]
pub enum OutputFormat {
#[default]
Text,
Json,
}
#[derive(Args, Debug, Clone, Default)]
pub struct SyncArgs {
#[arg(value_name = "DEST")]
pub dest: Option<PathBuf>,
#[arg(long, value_enum, value_name = "FORMAT")]
pub format: Option<AudioFmt>,
#[arg(long, value_name = "N")]
pub limit: Option<usize>,
#[arg(long, value_name = "SPEC")]
pub since: Option<String>,
#[arg(long, value_name = "N")]
pub min_newest: Option<u32>,
#[arg(long, value_name = "N")]
pub retries: Option<u32>,
#[arg(long, value_name = "N", hide = true)]
pub concurrency: Option<u32>,
#[arg(long)]
pub animated_covers: bool,
#[arg(long)]
pub allow_account_change: bool,
#[arg(long)]
pub details_sidecar: bool,
#[arg(long)]
pub lyrics_sidecar: bool,
#[arg(long)]
pub liked: bool,
#[arg(long, value_name = "ID_OR_NAME")]
pub playlist: Vec<String>,
#[arg(long)]
pub lrc_sidecar: bool,
#[arg(long, value_name = "TEMPLATE")]
pub naming_template: Option<String>,
#[arg(long, value_enum, value_name = "SET")]
pub character_set: Option<Charset>,
}
#[derive(Args, Debug, Clone, Default)]
pub struct CheckArgs {
#[command(flatten)]
pub sync: SyncArgs,
#[arg(long)]
pub exit_code: bool,
}
#[derive(Args, Debug, Clone, Default)]
pub struct LsArgs {
#[arg(long)]
pub liked: bool,
#[arg(long, value_name = "N")]
pub limit: Option<usize>,
#[arg(long, value_name = "SPEC")]
pub since: Option<String>,
#[arg(long, value_enum, value_name = "FORMAT", default_value_t = OutputFormat::Text)]
pub format: OutputFormat,
}
#[derive(Args, Debug, Clone)]
pub struct FetchArgs {
#[arg(value_name = "ID_OR_URL")]
pub id: String,
#[arg(value_name = "DEST")]
pub dest: Option<PathBuf>,
#[arg(long, value_enum, value_name = "FORMAT")]
pub format: Option<AudioFmt>,
#[arg(short = 'o', long, value_name = "PATH")]
pub output: Option<PathBuf>,
}
#[derive(Args, Debug)]
pub struct ConfigArgs {
#[command(subcommand)]
pub command: ConfigCommand,
}
#[derive(Subcommand, Debug)]
pub enum ConfigCommand {
Init,
AddAccount(ConfigAddAccountArgs),
Show,
}
#[derive(Args, Debug)]
pub struct ConfigAddAccountArgs {
#[arg(value_name = "LABEL")]
pub label: Option<String>,
#[arg(long, value_name = "TOKEN", hide = true)]
pub token: Option<String>,
}
#[derive(Args, Debug)]
pub struct AuthArgs {
#[command(subcommand)]
pub command: AuthCommand,
}
#[derive(Subcommand, Debug)]
pub enum AuthCommand {
Refresh(AuthRefreshArgs),
}
#[derive(Args, Debug)]
pub struct AuthRefreshArgs {
#[arg(value_name = "ACCOUNT")]
pub account: Option<String>,
}
#[derive(Args, Debug)]
pub struct CompletionsArgs {
#[arg(value_name = "SHELL")]
pub shell: clap_complete::Shell,
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn verbosity_combines_counts() {
let g = GlobalArgs {
verbose: 2,
quiet: 0,
..Default::default()
};
assert_eq!(g.verbosity(), 2);
let g = GlobalArgs {
verbose: 0,
quiet: 2,
..Default::default()
};
assert_eq!(g.verbosity(), -2);
let g = GlobalArgs {
verbose: 1,
quiet: 1,
..Default::default()
};
assert_eq!(g.verbosity(), 0);
}
#[test]
fn account_and_all_conflict() {
let result = Cli::try_parse_from(["suno", "--account", "a", "--all", "ls"]);
assert!(result.is_err());
}
#[test]
fn sync_parses_dest_and_flags() {
let cli = Cli::try_parse_from([
"suno",
"sync",
"/music",
"--format",
"mp3",
"--limit",
"5",
"--min-newest",
"0",
])
.unwrap();
match cli.command {
Command::Sync(args) => {
assert_eq!(args.dest.as_deref(), Some(std::path::Path::new("/music")));
assert_eq!(args.format, Some(AudioFmt::Mp3));
assert_eq!(args.limit, Some(5));
assert_eq!(args.min_newest, Some(0));
assert!(!args.animated_covers);
}
_ => panic!("expected sync"),
}
}
#[test]
fn sync_parses_animated_covers_flag() {
let cli = Cli::try_parse_from(["suno", "sync", "/music", "--animated-covers"]).unwrap();
match cli.command {
Command::Sync(args) => assert!(args.animated_covers),
_ => panic!("expected sync"),
}
let cli = Cli::try_parse_from(["suno", "copy", "/music"]).unwrap();
match cli.command {
Command::Copy(args) => assert!(!args.animated_covers),
_ => panic!("expected copy"),
}
}
#[test]
fn sync_parses_allow_account_change_flag() {
let cli = Cli::try_parse_from(["suno", "sync", "/music"]).unwrap();
match cli.command {
Command::Sync(args) => assert!(!args.allow_account_change),
_ => panic!("expected sync"),
}
let cli =
Cli::try_parse_from(["suno", "sync", "/music", "--allow-account-change"]).unwrap();
match cli.command {
Command::Sync(args) => assert!(args.allow_account_change),
_ => panic!("expected sync"),
}
}
#[test]
fn sync_parses_text_sidecar_flags() {
let cli = Cli::try_parse_from([
"suno",
"sync",
"/music",
"--details-sidecar",
"--lyrics-sidecar",
"--lrc-sidecar",
])
.unwrap();
match cli.command {
Command::Sync(args) => {
assert!(args.details_sidecar);
assert!(args.lyrics_sidecar);
assert!(args.lrc_sidecar);
}
_ => panic!("expected sync"),
}
let cli = Cli::try_parse_from(["suno", "sync", "/music"]).unwrap();
match cli.command {
Command::Sync(args) => {
assert!(!args.details_sidecar);
assert!(!args.lyrics_sidecar);
assert!(!args.lrc_sidecar);
}
_ => panic!("expected sync"),
}
}
#[test]
fn sync_parses_scope_flags() {
let cli = Cli::try_parse_from([
"suno",
"sync",
"/music",
"--liked",
"--playlist",
"Chill",
"--playlist",
"id-42",
])
.unwrap();
match cli.command {
Command::Sync(args) => {
assert!(args.liked);
assert_eq!(args.playlist, vec!["Chill".to_owned(), "id-42".to_owned()]);
}
_ => panic!("expected sync"),
}
let cli = Cli::try_parse_from(["suno", "copy", "/music"]).unwrap();
match cli.command {
Command::Copy(args) => {
assert!(!args.liked);
assert!(args.playlist.is_empty());
}
_ => panic!("expected copy"),
}
}
#[test]
fn check_flattens_scope_flags() {
let cli =
Cli::try_parse_from(["suno", "check", "/music", "--liked", "--playlist", "Focus"])
.unwrap();
match cli.command {
Command::Check(args) => {
assert!(args.sync.liked);
assert_eq!(args.sync.playlist, vec!["Focus".to_owned()]);
}
_ => panic!("expected check"),
}
}
#[test]
fn global_flags_accepted_after_subcommand() {
let cli =
Cli::try_parse_from(["suno", "sync", "/music", "--dry-run", "-vv", "--yes"]).unwrap();
assert!(cli.global.dry_run);
assert!(cli.global.yes);
assert_eq!(cli.global.verbosity(), 2);
}
#[test]
fn check_has_exit_code_flag() {
let cli = Cli::try_parse_from(["suno", "check", "/music", "--exit-code"]).unwrap();
match cli.command {
Command::Check(args) => assert!(args.exit_code),
_ => panic!("expected check"),
}
}
#[test]
fn lsjson_and_ls_share_flags() {
let cli = Cli::try_parse_from(["suno", "lsjson", "--liked", "--limit", "3"]).unwrap();
match cli.command {
Command::Lsjson(args) => {
assert!(args.liked);
assert_eq!(args.limit, Some(3));
}
_ => panic!("expected lsjson"),
}
}
#[test]
fn completions_parses_shell() {
let cli = Cli::try_parse_from(["suno", "completions", "bash"]).unwrap();
assert!(matches!(cli.command, Command::Completions(_)));
}
#[test]
fn audio_fmt_maps_to_core() {
assert_eq!(AudioFormat::from(AudioFmt::Flac), AudioFormat::Flac);
assert_eq!(AudioFormat::from(AudioFmt::Mp3), AudioFormat::Mp3);
assert_eq!(AudioFormat::from(AudioFmt::Wav), AudioFormat::Wav);
}
#[test]
fn charset_maps_to_core() {
assert_eq!(CharacterSet::from(Charset::Unicode), CharacterSet::Unicode);
assert_eq!(CharacterSet::from(Charset::Ascii), CharacterSet::Ascii);
}
#[test]
fn sync_parses_naming_template_and_character_set() {
let cli = Cli::try_parse_from([
"suno",
"sync",
"/music",
"--naming-template",
"{title}/{id8}",
"--character-set",
"ascii",
])
.unwrap();
match cli.command {
Command::Sync(args) => {
assert_eq!(args.naming_template.as_deref(), Some("{title}/{id8}"));
assert_eq!(args.character_set, Some(Charset::Ascii));
}
_ => panic!("expected sync"),
}
let cli = Cli::try_parse_from(["suno", "sync", "/music"]).unwrap();
match cli.command {
Command::Sync(args) => {
assert_eq!(args.naming_template, None);
assert_eq!(args.character_set, None);
}
_ => panic!("expected sync"),
}
}
}