use std::collections::HashMap;
use std::fmt::Write as _;
use std::path::PathBuf;
use anyhow::{Context, Result};
use suno_core::{
ClerkAuth, Config, EffectiveSettings, FlagOverrides, SunoClient, TOKEN_EXPIRY_WARN_DAYS,
TokenExpiry,
};
use crate::cli::args::GlobalArgs;
use crate::cli::commands::version;
use crate::cli::desired::ExitCode;
use crate::cli::logs;
use crate::cli::run;
use crate::clock::TokioClock;
use crate::http::ReqwestHttp;
const SECS_PER_DAY: i64 = 86_400;
pub async fn run_doctor(global: &GlobalArgs) -> Result<ExitCode> {
let env: HashMap<String, String> = std::env::vars().collect();
let flags = FlagOverrides {
token: global.token.clone(),
..FlagOverrides::default()
};
let config_path = logs::config_path(global.config.as_deref());
let config_diag = inspect_config(
config_path.clone(),
global.config.is_some() || env.contains_key("SUNO_CONFIG"),
)?;
let mut out = String::new();
let mut worst = config_diag.exit_code.unwrap_or(ExitCode::Ok);
writeln!(
out,
"suno {} ({})",
env!("CARGO_PKG_VERSION"),
env!("SUNO_TARGET")
)
.ok();
match version::ffmpeg_version() {
Some((found, path)) => writeln!(out, "ffmpeg: {found} (detected at {path})").ok(),
None => writeln!(out, "ffmpeg: not found on PATH").ok(),
};
render_env_section(&mut out, &env);
render_config_section(&mut out, &config_diag);
match resolve_targets(config_diag.config.as_ref(), global, &env, &flags) {
Ok(targets) if targets.is_empty() => {}
Ok(targets) => {
let http = ReqwestHttp::new().context("failed to build the HTTP client")?;
for target in targets {
render_account_header(&mut out, &target.label);
render_account_env(&mut out, &env, &target.label);
render_account_config(&mut out, target.root.as_deref());
render_resolved_settings(&mut out, &target.settings);
match target.settings.token.as_deref() {
Some(token) => {
let live = inspect_live(token, &http).await;
render_live_status(&mut out, &live);
worst = max_exit_code(worst, live.exit_code);
}
None => {
writeln!(out, " auth: skipped (no token resolved)").ok();
writeln!(out, " credits: skipped (auth unavailable)").ok();
worst = max_exit_code(worst, ExitCode::Config);
}
}
}
}
Err(message) => {
writeln!(out).ok();
writeln!(out, "account selection: {message}").ok();
worst = max_exit_code(worst, ExitCode::Config);
}
}
print!("{out}");
Ok(worst)
}
struct ConfigDiagnostic {
path: Option<PathBuf>,
config: Option<Config>,
status: String,
exit_code: Option<ExitCode>,
}
fn inspect_config(path: Option<PathBuf>, explicit: bool) -> Result<ConfigDiagnostic> {
let Some(path) = path else {
return Ok(ConfigDiagnostic {
path: None,
config: None,
status: "unavailable (no config directory could be determined)".to_owned(),
exit_code: None,
});
};
match std::fs::read_to_string(&path) {
Ok(text) => match Config::from_toml(&text) {
Ok(config) => {
let status = format!("parsed ({} account(s))", config.accounts.len());
Ok(ConfigDiagnostic {
path: Some(path),
config: Some(config),
status,
exit_code: None,
})
}
Err(err) => Ok(ConfigDiagnostic {
path: Some(path),
config: None,
status: format!("invalid ({err})"),
exit_code: Some(ExitCode::Config),
}),
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(ConfigDiagnostic {
path: Some(path),
config: None,
status: "not found".to_owned(),
exit_code: explicit.then_some(ExitCode::Config),
}),
Err(err) => Ok(ConfigDiagnostic {
path: Some(path),
config: None,
status: format!("unreadable ({err})"),
exit_code: Some(ExitCode::Config),
}),
}
}
struct DoctorTarget {
label: String,
settings: EffectiveSettings,
root: Option<String>,
}
fn resolve_targets(
config: Option<&Config>,
global: &GlobalArgs,
env: &HashMap<String, String>,
flags: &FlagOverrides,
) -> std::result::Result<Vec<DoctorTarget>, String> {
if global.all {
let cfg = config.ok_or_else(|| "--all requires a valid config file".to_owned())?;
let mut labels: Vec<String> = cfg.accounts.keys().cloned().collect();
labels.sort();
if labels.is_empty() {
return Err("no accounts are configured".to_owned());
}
return labels
.into_iter()
.map(|label| {
let settings = cfg
.resolve(&label, None, env, flags)
.map_err(|err| err.to_string())?;
let root = cfg
.accounts
.get(&label)
.and_then(|account| account.root.clone());
Ok(DoctorTarget {
label,
settings,
root,
})
})
.collect();
}
let (label, settings) = run::single_account(config, global, flags, env)?;
let root = config
.and_then(|cfg| cfg.accounts.get(&label))
.and_then(|account| account.root.clone());
Ok(vec![DoctorTarget {
label,
settings,
root,
}])
}
struct LiveDiagnostic {
auth_line: String,
credits_line: String,
exit_code: ExitCode,
}
async fn inspect_live(token: &str, http: &ReqwestHttp) -> LiveDiagnostic {
let mut auth = ClerkAuth::new(token);
let now = i64::try_from(run::now_secs()).unwrap_or(i64::MAX);
let expiry = auth.token_expiry(now, TOKEN_EXPIRY_WARN_DAYS * SECS_PER_DAY);
match auth.authenticate(http).await {
Ok(user_id) => {
let display_name = auth.display_name().to_owned();
let mut client = SunoClient::new(auth, TokioClock);
match client.get_billing_info(http).await {
Ok(billing) => LiveDiagnostic {
auth_line: format!(
"ok as {display_name} ({user_id}); token expiry: {}",
describe_expiry(expiry)
),
credits_line: format!("{} remaining", billing.total_credits_left),
exit_code: ExitCode::Ok,
},
Err(err) => LiveDiagnostic {
auth_line: format!(
"ok as {display_name} ({user_id}); token expiry: {}",
describe_expiry(expiry)
),
credits_line: format!("unavailable ({err})"),
exit_code: exit_code_for_core_error(&err),
},
}
}
Err(err) => LiveDiagnostic {
auth_line: format!("failed ({err}); token expiry: {}", describe_expiry(expiry)),
credits_line: "skipped (auth failed)".to_owned(),
exit_code: exit_code_for_core_error(&err),
},
}
}
fn render_env_section(out: &mut String, env: &HashMap<String, String>) {
writeln!(out).ok();
writeln!(out, "env:").ok();
match env.get("SUNO_CONFIG") {
Some(path) => writeln!(out, " SUNO_CONFIG: set ({path})").ok(),
None => writeln!(out, " SUNO_CONFIG: unset").ok(),
};
writeln!(
out,
" SUNO_TOKEN: {}",
present(env.contains_key("SUNO_TOKEN"))
)
.ok();
}
fn render_config_section(out: &mut String, config: &ConfigDiagnostic) {
writeln!(out).ok();
writeln!(out, "config:").ok();
match &config.path {
Some(path) => writeln!(out, " path: {}", path.display()).ok(),
None => writeln!(out, " path: (none)").ok(),
};
writeln!(out, " status: {}", config.status).ok();
if let Some(config) = &config.config {
let mut labels: Vec<&str> = config.accounts.keys().map(String::as_str).collect();
labels.sort_unstable();
writeln!(out, " accounts: {}", labels.join(", ")).ok();
}
}
fn render_account_header(out: &mut String, label: &str) {
writeln!(out).ok();
writeln!(out, "account '{label}':").ok();
}
fn render_account_env(out: &mut String, env: &HashMap<String, String>, label: &str) {
let name = format!("SUNO_{}_TOKEN", label_to_env(label));
writeln!(out, " env:").ok();
writeln!(out, " {name}: {}", present(env.contains_key(&name))).ok();
}
fn render_account_config(out: &mut String, root: Option<&str>) {
writeln!(out, " config:").ok();
match root {
Some(root) => writeln!(out, " root: {root}").ok(),
None => writeln!(out, " root: [not set]").ok(),
};
}
fn render_resolved_settings(out: &mut String, settings: &EffectiveSettings) {
writeln!(out, " resolved:").ok();
writeln!(
out,
" token: {}",
if settings.token.is_some() {
"[redacted]"
} else {
"[not set]"
}
)
.ok();
writeln!(
out,
" account_id: {}",
settings.account_id.as_deref().unwrap_or("[not set]")
)
.ok();
writeln!(out, " format: {}", settings.format).ok();
writeln!(out, " concurrency: {}", settings.concurrency).ok();
writeln!(out, " retries: {}", settings.retries).ok();
writeln!(out, " min_newest: {}", settings.min_newest).ok();
writeln!(out, " animated_covers: {}", settings.animated_covers).ok();
writeln!(out, " details_sidecar: {}", settings.details_sidecar).ok();
writeln!(out, " lyrics_sidecar: {}", settings.lyrics_sidecar).ok();
writeln!(out, " lrc_sidecar: {}", settings.lrc_sidecar).ok();
writeln!(out, " video_mp4: {}", settings.video_mp4).ok();
writeln!(out, " download_stems: {}", settings.download_stems).ok();
writeln!(out, " stem_format: {}", settings.stem_format).ok();
writeln!(out, " naming_template: {}", settings.naming_template).ok();
writeln!(out, " character_set: {}", settings.character_set).ok();
writeln!(
out,
" areas: {}",
if settings.areas.is_some() {
"configured"
} else {
"[not set]"
}
)
.ok();
}
fn render_live_status(out: &mut String, live: &LiveDiagnostic) {
writeln!(out, " auth: {}", live.auth_line).ok();
writeln!(out, " credits: {}", live.credits_line).ok();
}
fn describe_expiry(expiry: TokenExpiry) -> String {
match expiry {
TokenExpiry::Fresh => "fresh".to_owned(),
TokenExpiry::Unknown => "unknown".to_owned(),
TokenExpiry::Expiring { days } => format!("expiring in {days} day(s)"),
TokenExpiry::Expired => "expired".to_owned(),
}
}
fn exit_code_for_core_error(err: &suno_core::Error) -> ExitCode {
match err {
suno_core::Error::Auth(_) => ExitCode::Auth,
suno_core::Error::Config(_) => ExitCode::Config,
suno_core::Error::Connection(_) | suno_core::Error::RateLimited { .. } => {
ExitCode::Transient
}
_ => ExitCode::General,
}
}
fn label_to_env(label: &str) -> String {
label.to_ascii_uppercase().replace('-', "_")
}
fn present(yes: bool) -> &'static str {
if yes { "set" } else { "unset" }
}
fn max_exit_code(a: ExitCode, b: ExitCode) -> ExitCode {
if b.code() >= a.code() { b } else { a }
}
#[cfg(test)]
mod tests {
use super::*;
use suno_core::{AudioFormat, CharacterSet};
fn settings() -> EffectiveSettings {
EffectiveSettings {
token: Some("eyJsupersecret".to_owned()),
stored_token: None,
token_command: None,
account_id: Some("acct-123".to_owned()),
format: AudioFormat::Flac,
concurrency: 4,
retries: 3,
min_newest: 1,
animated_covers: true,
details_sidecar: false,
lyrics_sidecar: true,
lrc_sidecar: false,
video_mp4: true,
download_stems: false,
stem_format: suno_core::StemFormat::Wav,
naming_template: "{title}/{id8}".to_owned(),
character_set: CharacterSet::Ascii,
areas: None,
album_overrides: std::collections::BTreeMap::new(),
}
}
#[test]
fn render_resolved_settings_redacts_token() {
let mut out = String::new();
render_resolved_settings(&mut out, &settings());
assert!(out.contains("token: [redacted]"));
assert!(!out.contains("eyJsupersecret"));
assert!(out.contains("format: flac"));
assert!(out.contains("character_set: ascii"));
}
#[test]
fn describe_expiry_covers_every_state() {
assert_eq!(describe_expiry(TokenExpiry::Fresh), "fresh");
assert_eq!(describe_expiry(TokenExpiry::Unknown), "unknown");
assert_eq!(
describe_expiry(TokenExpiry::Expiring { days: 2 }),
"expiring in 2 day(s)"
);
assert_eq!(describe_expiry(TokenExpiry::Expired), "expired");
}
#[test]
fn inspect_config_marks_default_missing_as_non_fatal() {
let stamp = format!("doctor-missing-{}", std::process::id());
let path = std::env::temp_dir().join(stamp);
let diag = inspect_config(Some(path.clone()), false).unwrap();
assert_eq!(diag.path.as_deref(), Some(path.as_path()));
assert_eq!(diag.status, "not found");
assert_eq!(diag.exit_code, None);
}
#[test]
fn inspect_config_marks_explicit_missing_as_config_error() {
let stamp = format!("doctor-explicit-missing-{}", std::process::id());
let path = std::env::temp_dir().join(stamp);
let diag = inspect_config(Some(path), true).unwrap();
assert_eq!(diag.status, "not found");
assert_eq!(diag.exit_code, Some(ExitCode::Config));
}
}