use std::io;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("missing required authentication setting: {0}")]
MissingAuthConfig(&'static str),
#[error("token file not found: {0}")]
TokenFileMissing(String),
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Schwab error: {0}")]
Schwab(#[from] schwab::Error),
#[error("{0}")]
OrderValidation(String),
#[error("{0}")]
AccountValidation(String),
#[error("unexpected Schwab {endpoint} response shape: expected {expected}; got {shape}")]
AccountResponseShape {
endpoint: &'static str,
expected: &'static str,
shape: String,
},
#[error("symbol has no listed options: {symbol}")]
OptionsSymbolNotFound { symbol: String },
#[error("{message}")]
OptionsValidation { message: String },
#[error("{message}")]
MarketValidation { message: String },
#[error("not enough candle data for {indicator}: need {needed} candles, got {got}")]
TaInsufficientData {
needed: usize,
got: usize,
indicator: String,
},
#[error(
"unrecognized interval '{interval}': valid values are daily, weekly, 1min, 5min, 15min, 30min"
)]
TaInvalidInterval { interval: String },
#[error("TA calculation error in {indicator}: {reason}")]
TaCalculationError { indicator: String, reason: String },
#[error("preview error: {0}")]
Preview(String),
#[error("mutable operations are disabled by default")]
MutableDisabled,
#[error("{message}")]
CommandMigration {
message: &'static str,
hint: &'static str,
},
}
impl AppError {
#[must_use]
pub fn exit_code(&self) -> i32 {
match self {
Self::MissingAuthConfig(_) | Self::TokenFileMissing(_) => 3,
Self::Io(_) | Self::Json(_) => 20,
Self::Schwab(error) => classify_schwab_error(error).0,
Self::OrderValidation(_) => 10,
Self::AccountValidation(_) => 10,
Self::AccountResponseShape { .. } => 20,
Self::OptionsSymbolNotFound { .. } | Self::OptionsValidation { .. } => 10,
Self::MarketValidation { .. } => 10,
Self::TaInsufficientData { .. } | Self::TaInvalidInterval { .. } => 10,
Self::TaCalculationError { .. } => 20,
Self::Preview(_) => 11,
Self::MutableDisabled => 10,
Self::CommandMigration { .. } => 2,
}
}
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::MissingAuthConfig(_) => "auth.config_missing",
Self::TokenFileMissing(_) => "auth.token_missing",
Self::Io(_) => "io.error",
Self::Json(_) => "json.error",
Self::Schwab(error) => classify_schwab_error(error).1,
Self::OrderValidation(_) => "order.validation_failed",
Self::AccountValidation(_) => "account.validation_failed",
Self::AccountResponseShape { .. } => "account.response_shape",
Self::OptionsSymbolNotFound { .. } => "options.symbol_not_found",
Self::OptionsValidation { .. } => "options.validation_failed",
Self::MarketValidation { .. } => "market.validation_failed",
Self::TaInsufficientData { .. } => "ta.insufficient_data",
Self::TaInvalidInterval { .. } => "ta.invalid_interval",
Self::TaCalculationError { .. } => "ta.calculation_error",
Self::Preview(_) => "order.preview_failed",
Self::MutableDisabled => "config.mutable_disabled",
Self::CommandMigration { .. } => "usage.migration",
}
}
#[must_use]
pub fn category(&self) -> &'static str {
match self {
Self::MissingAuthConfig(_) | Self::TokenFileMissing(_) => "auth",
Self::Io(_) => "io",
Self::Json(_) => "json",
Self::Schwab(error) => classify_schwab_error(error).2,
Self::OrderValidation(_) | Self::Preview(_) => "order",
Self::AccountValidation(_) | Self::AccountResponseShape { .. } => "account",
Self::OptionsSymbolNotFound { .. } | Self::OptionsValidation { .. } => "options",
Self::MarketValidation { .. } => "market",
Self::TaInsufficientData { .. }
| Self::TaInvalidInterval { .. }
| Self::TaCalculationError { .. } => "ta",
Self::MutableDisabled => "config",
Self::CommandMigration { .. } => "usage",
}
}
#[must_use]
pub fn retryable(&self) -> bool {
matches!(self, Self::Schwab(schwab::Error::Request(_)))
}
#[must_use]
pub fn hint(&self) -> Option<&'static str> {
match self {
Self::MissingAuthConfig(_) => Some(
"Add client_id and client_secret to ~/.config/schwab-agent/config.json or set SCHWAB_CLIENT_ID and SCHWAB_CLIENT_SECRET.",
),
Self::TokenFileMissing(_) => {
Some("Run auth login-url, then auth exchange, to create a token file.")
}
Self::OptionsSymbolNotFound { .. } => {
Some("Check that the symbol is correct and has listed options")
}
Self::AccountValidation(_) => {
Some("Run account to list available account hashes and nicknames.")
}
Self::AccountResponseShape { .. } => Some(
"Schwab returned an account response shape this version does not recognize. Update schwab-agent or report the sanitized shape metadata.",
),
Self::MarketValidation { .. } => Some(
"Use --fields with one or more supported quote output fields, for example sym,last,pct,vol.",
),
Self::Schwab(schwab::Error::RefreshTokenInvalid) => {
Some("Run auth login, or use auth login-url and auth exchange, to re-authenticate.")
}
Self::Schwab(schwab::Error::AuthExpired | schwab::Error::AuthRequired) => {
Some("Run auth refresh, or re-authenticate with auth login-url and auth exchange.")
}
Self::TaInsufficientData { .. } => Some("Try a shorter interval or fewer --points."),
Self::TaInvalidInterval { .. } => {
Some("Valid intervals: daily, weekly, 1min, 5min, 15min, 30min")
}
Self::MutableDisabled => Some(
"Set \"i-also-like-to-live-dangerously\": true in ~/.config/schwab-agent/config.json to enable order placement, cancellation, and replacement.",
),
Self::CommandMigration { hint, .. } => Some(hint),
_ => None,
}
}
}
fn classify_schwab_error(error: &schwab::Error) -> (i32, &'static str, &'static str) {
match error {
schwab::Error::AuthRequired => (3, "auth.required", "auth"),
schwab::Error::AuthExpired => (3, "auth.expired", "auth"),
schwab::Error::RefreshTokenInvalid => (3, "auth.refresh_token_invalid", "auth"),
schwab::Error::AuthCallback(_) => (3, "auth.callback_failed", "auth"),
schwab::Error::HttpStatus { .. } => (4, "schwab.http_status", "schwab"),
schwab::Error::Request(_) => (1, "schwab.request_failed", "schwab"),
schwab::Error::Decode { .. } => (1, "schwab.decode_failed", "schwab"),
schwab::Error::Json(_) => (1, "auth.json_failed", "auth"),
schwab::Error::Io(_) => (1, "auth.io_failed", "auth"),
schwab::Error::EmptySymbols => (10, "input.empty_symbols", "input"),
schwab::Error::MissingRequiredParameter(_) => (10, "input.missing_parameter", "input"),
schwab::Error::InvalidAuthConfig { .. } => (20, "auth.config_invalid", "auth"),
schwab::Error::EmptyBaseUrl | schwab::Error::InvalidBaseUrl { .. } => {
(20, "config.base_url_invalid", "config")
}
schwab::Error::Encode(_) => (1, "json.encode_failed", "json"),
#[allow(unreachable_patterns)]
_ => (1, "schwab.unknown", "schwab"),
}
}
#[cfg(test)]
mod tests;