sunox 0.0.14

Generate AI music from your terminal via direct Suno web workflows
use thiserror::Error;

#[derive(Error, Debug)]
pub enum CliError {
    #[error("API error: {message}")]
    Api { code: &'static str, message: String },

    #[error("API error: {message}")]
    SunoApi {
        code: &'static str,
        status: u16,
        message: String,
        retryable: Option<bool>,
        details: Option<serde_json::Value>,
    },

    #[error("Partial mutation failure: {message}")]
    PartialMutation {
        message: String,
        details: serde_json::Value,
    },

    #[error("Partial download failure: {message}")]
    PartialDownload {
        message: String,
        details: serde_json::Value,
    },

    #[error("Authentication required — run `sunox login` first")]
    AuthMissing,

    #[error("JWT expired or rejected by Suno")]
    AuthExpired,

    #[error("Active Suno authentication changed while the command was in progress")]
    AuthChanged,

    #[error("Rate limited by Suno — wait and retry")]
    RateLimited,

    #[error("Generation failed: {0}")]
    GenerationFailed(String),

    #[error("Configuration error: {0}")]
    Config(String),

    #[error("Download failed: {0}")]
    Download(String),

    #[error("Not found: {0}")]
    NotFound(String),

    #[error("Self-update failed: {0}")]
    Update(String),

    #[error(transparent)]
    Http(#[from] reqwest::Error),

    #[error(transparent)]
    Io(#[from] std::io::Error),

    #[error(transparent)]
    Json(#[from] serde_json::Error),
}

impl CliError {
    pub(crate) fn is_auth_or_rate_limit(&self) -> bool {
        matches!(
            self,
            Self::AuthMissing | Self::AuthExpired | Self::AuthChanged | Self::RateLimited
        )
    }

    pub fn exit_code(&self) -> i32 {
        match self {
            Self::Config(_) => 2,
            Self::AuthMissing | Self::AuthExpired | Self::AuthChanged => 3,
            Self::RateLimited => 4,
            Self::NotFound(_) => 5,
            Self::Api { .. }
            | Self::SunoApi { .. }
            | Self::PartialMutation { .. }
            | Self::PartialDownload { .. }
            | Self::Http(_)
            | Self::GenerationFailed(_)
            | Self::Download(_)
            | Self::Update(_)
            | Self::Io(_)
            | Self::Json(_) => 1,
        }
    }

    pub fn error_code(&self) -> &'static str {
        match self {
            Self::Api { code, .. } => code,
            Self::SunoApi { code, .. } => code,
            Self::PartialMutation { .. } => "partial_mutation",
            Self::PartialDownload { .. } => "partial_download",
            Self::AuthMissing => "auth_missing",
            Self::AuthExpired => "auth_expired",
            Self::AuthChanged => "auth_changed",
            Self::RateLimited => "rate_limited",
            Self::Config(_) => "config_error",
            Self::GenerationFailed(_) => "generation_failed",
            Self::Download(_) => "download_error",
            Self::NotFound(_) => "not_found",
            Self::Http(_) => "http_error",
            Self::Io(_) => "io_error",
            Self::Json(_) => "json_error",
            Self::Update(_) => "update_error",
        }
    }

    pub fn suggestion(&self) -> &'static str {
        match self {
            Self::AuthMissing => "Run `sunox login` to authenticate",
            Self::AuthExpired => "Run `sunox auth --refresh`; if that fails, run `sunox login`",
            Self::AuthChanged => {
                "Retry the command with the current login; run `sunox auth` if the account switch was unintended"
            }
            Self::RateLimited => "Wait 30-60 seconds and retry",
            Self::Config(_) => "Check `sunox doctor` for configuration issues",
            Self::NotFound(_) => {
                "Verify the ID exists with `sunox clip list` or `sunox clip search`"
            }
            Self::Download(_) => {
                "Check that the clip has finished generating with `sunox clip status <id>`"
            }
            Self::GenerationFailed(_) => {
                "Inspect the failure message and retry only after addressing the reported cause"
            }
            Self::PartialMutation { .. } => {
                "Inspect error.details before retrying; when recovery is present, follow it only if recovery.resumable is true"
            }
            Self::PartialDownload { .. } => {
                "Inspect error.details for succeeded paths, the failed clip, and not_attempted IDs before retrying"
            }
            Self::Api { code, .. } if *code == "schema_drift" => {
                "Suno changed its web schema or challenge enforcement. Try (1) `sunox auth --refresh` to mint a fresh JWT, (2) `sunox update` to pull the latest fix, (3) supply a challenge token via `--token <solved>`, or (4) see https://github.com/ctykwz/sunox/issues for the current status"
            }
            Self::SunoApi { code, .. } if *code == "schema_drift" => {
                "Suno changed its web schema. Run `sunox update`; if the error remains, report the response details"
            }
            Self::SunoApi {
                retryable: Some(false),
                ..
            } => {
                "Do not retry the same request unchanged; inspect error.details and correct the request or resource state"
            }
            Self::SunoApi {
                retryable: Some(true),
                ..
            } => "Suno marked this failure as retryable; wait before retrying",
            Self::SunoApi { status: 404, .. } => {
                "Verify the resource ID and whether this Suno web route is still available"
            }
            Self::SunoApi { status, .. } if (400..500).contains(status) => {
                "Inspect error.details and correct the request before retrying"
            }
            Self::SunoApi { status, .. } if *status >= 500 => {
                "Suno returned a server error with unknown retryability; inspect error.details before deciding whether to retry"
            }
            Self::SunoApi { .. } => "Inspect error.details before retrying",
            Self::Api { .. } => {
                "Inspect the Suno error response and retry only when it explicitly indicates the request is retryable"
            }
            Self::Http(_) => "Check your network connection and retry",
            Self::Io(_) => "Check file permissions and disk space",
            Self::Json(_) => {
                "This may indicate a response schema change — run `sunox update` for the latest fix"
            }
            Self::Update(_) => {
                "Check your network connection or download the binary directly from GitHub Releases"
            }
        }
    }

    pub fn details(&self) -> Option<&serde_json::Value> {
        match self {
            Self::PartialMutation { details, .. }
            | Self::PartialDownload { details, .. }
            | Self::SunoApi {
                details: Some(details),
                ..
            } => Some(details),
            _ => None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::CliError;

    #[test]
    fn partial_download_exposes_machine_readable_details() {
        let details = serde_json::json!({"succeeded": []});
        let error = CliError::PartialDownload {
            message: "one download failed".into(),
            details: details.clone(),
        };

        assert_eq!(error.error_code(), "partial_download");
        assert_eq!(error.details(), Some(&details));
    }

    #[test]
    fn generation_failure_suggestion_does_not_assume_a_credit_problem() {
        let error = CliError::GenerationFailed("timed out waiting for edit action".into());

        assert!(!error.suggestion().to_ascii_lowercase().contains("credit"));
        assert!(error.suggestion().contains("failure message"));
    }

    #[test]
    fn suno_api_error_does_not_claim_a_network_failure() {
        let error = CliError::SunoApi {
            code: "api_error",
            status: 500,
            message: "HTTP 500: {\"retryable\":false}".into(),
            retryable: Some(false),
            details: Some(serde_json::json!({"retryable": false})),
        };

        assert!(!error.suggestion().to_ascii_lowercase().contains("network"));
        assert!(error.suggestion().starts_with("Do not retry"));
        assert_eq!(
            error.details(),
            Some(&serde_json::json!({"retryable": false}))
        );
    }
}