use std::path::PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
use suno_core::AudioFormat;
#[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, 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,
}
#[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 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);
}
}