Skip to main content

osdk_core/
error.rs

1//! Error types for osdk-core.
2
3use std::fmt;
4use std::path::PathBuf;
5
6pub type Result<T> = std::result::Result<T, Error>;
7
8#[derive(Debug, thiserror::Error)]
9pub enum Error {
10    #[error("io error at {path}: {source}")]
11    Io {
12        path: PathBuf,
13        #[source]
14        source: std::io::Error,
15    },
16
17    #[error(transparent)]
18    PlainIo(#[from] std::io::Error),
19
20    #[error("http error: {0}")]
21    Http(#[from] reqwest::Error),
22
23    #[error("network {kind}: {url}{status}", status = .status.map(|status| format!(" ({status})")).unwrap_or_default())]
24    Network {
25        kind: NetworkErrorKind,
26        url: String,
27        status: Option<u16>,
28    },
29
30    #[error(
31        "GitHub API rate limit exceeded for {url} ({status}){info}; {guidance}",
32        guidance = if *authenticated {
33            "retry later or check the configured GitHub token's quota and permissions"
34        } else {
35            "retry later or set OSDK_GITHUB_TOKEN (or GITHUB_TOKEN/GH_TOKEN) for a higher API quota"
36        }
37    )]
38    GithubRateLimited {
39        url: String,
40        status: u16,
41        authenticated: bool,
42        info: GithubRateLimitInfo,
43    },
44
45    #[error("config error: {0}")]
46    Config(String),
47
48    #[error("toml parse error: {0}")]
49    TomlDe(#[from] toml::de::Error),
50
51    #[error("json error: {0}")]
52    Json(#[from] serde_json::Error),
53
54    #[error("tool `{0}` is not a known backend")]
55    UnknownBackend(String),
56
57    #[error("could not resolve version `{spec}` for `{tool}`{}", .hint.as_ref().map(|h| format!(": {h}")).unwrap_or_default())]
58    VersionResolve {
59        tool: String,
60        spec: String,
61        hint: Option<String>,
62    },
63
64    #[error("tool `{tool}@{version}` is not installed")]
65    NotInstalled { tool: String, version: String },
66
67    #[error("checksum mismatch for {name}: expected {expected}, got {actual}")]
68    ChecksumMismatch {
69        name: String,
70        expected: String,
71        actual: String,
72    },
73
74    #[error("no usable source for `{tool}`: all {tried} candidate(s) failed or were unreachable")]
75    NoUsableSource { tool: String, tried: usize },
76
77    #[error("unsupported archive: {0}")]
78    UnsupportedArchive(String),
79
80    #[error("unsupported platform: os={os}, arch={arch}")]
81    UnsupportedPlatform { os: String, arch: String },
82
83    #[error("external command `{cmd}` failed with status {status}{}", .stderr.as_ref().map(|s| format!(":\n{s}")).unwrap_or_default())]
84    Command {
85        cmd: String,
86        status: String,
87        stderr: Option<String>,
88    },
89
90    #[error("{0}")]
91    Other(String),
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum NetworkErrorKind {
96    Forbidden,
97    RateLimited,
98    Server,
99    Timeout,
100    Interrupted,
101    Connect,
102    InvalidMetadata,
103}
104
105#[derive(Debug, Clone, Default, PartialEq, Eq)]
106pub struct GithubRateLimitInfo {
107    pub message: Option<String>,
108    pub reset: Option<String>,
109    pub retry_after: Option<String>,
110}
111
112impl fmt::Display for GithubRateLimitInfo {
113    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
114        if let Some(message) = &self.message {
115            write!(formatter, "; {message}")?;
116        }
117        if let Some(reset) = &self.reset {
118            write!(formatter, "; resets at Unix time {reset}")?;
119        }
120        if let Some(retry_after) = &self.retry_after {
121            write!(formatter, "; retry after {retry_after}")?;
122        }
123        Ok(())
124    }
125}
126
127impl std::fmt::Display for NetworkErrorKind {
128    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        formatter.write_str(match self {
130            Self::Forbidden => "forbidden",
131            Self::RateLimited => "rate-limited",
132            Self::Server => "server-error",
133            Self::Timeout => "timeout",
134            Self::Interrupted => "interrupted",
135            Self::Connect => "connect-error",
136            Self::InvalidMetadata => "invalid-metadata",
137        })
138    }
139}
140
141impl Error {
142    pub fn other(msg: impl Into<String>) -> Self {
143        Error::Other(msg.into())
144    }
145
146    pub fn config(msg: impl Into<String>) -> Self {
147        Error::Config(msg.into())
148    }
149
150    /// Wrap an io error with the path it occurred at, for better diagnostics.
151    pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
152        Error::Io {
153            path: path.into(),
154            source,
155        }
156    }
157
158    pub fn network(url: &str, error: reqwest::Error) -> Self {
159        let status = error.status().map(|status| status.as_u16());
160        let kind = match error.status() {
161            Some(reqwest::StatusCode::FORBIDDEN) => NetworkErrorKind::Forbidden,
162            Some(reqwest::StatusCode::TOO_MANY_REQUESTS) => NetworkErrorKind::RateLimited,
163            Some(status) if status.is_server_error() => NetworkErrorKind::Server,
164            _ if error.is_timeout() => NetworkErrorKind::Timeout,
165            _ if error.is_body() || error.is_decode() => NetworkErrorKind::Interrupted,
166            _ => NetworkErrorKind::Connect,
167        };
168        Error::Network {
169            kind,
170            url: url.into(),
171            status,
172        }
173    }
174
175    pub fn is_anonymous_github_rate_limit(&self) -> bool {
176        matches!(
177            self,
178            Error::GithubRateLimited {
179                authenticated: false,
180                ..
181            }
182        )
183    }
184
185    pub fn status(&self) -> Option<u16> {
186        match self {
187            Error::Network { status, .. } => *status,
188            Error::GithubRateLimited { status, .. } => Some(*status),
189            _ => None,
190        }
191    }
192
193    /// A localized, user-facing message for this error in the active language.
194    ///
195    /// Structured variants map to catalog keys; free-form variants (`Other`,
196    /// `Config`, wrapped IO/HTTP errors) fall back to the Display text, which is
197    /// already meaningful.
198    pub fn localized(&self) -> String {
199        use crate::i18n::trf;
200        match self {
201            Error::UnknownBackend(name) => trf("err.unknown_backend", &[("name", name)]),
202            Error::NotInstalled { tool, version } => {
203                trf("err.not_installed", &[("tool", tool), ("ver", version)])
204            }
205            Error::NoUsableSource { tool, tried } => trf(
206                "err.no_usable_source",
207                &[("tool", tool), ("tried", &tried.to_string())],
208            ),
209            Error::ChecksumMismatch {
210                name,
211                expected,
212                actual,
213            } => trf(
214                "err.checksum_mismatch",
215                &[("name", name), ("expected", expected), ("actual", actual)],
216            ),
217            Error::VersionResolve { tool, spec, hint } => {
218                let base = trf("err.version_resolve", &[("tool", tool), ("spec", spec)]);
219                match hint {
220                    Some(h) => format!("{base}: {h}"),
221                    None => base,
222                }
223            }
224            Error::UnsupportedPlatform { os, arch } => {
225                trf("err.unsupported_platform", &[("os", os), ("arch", arch)])
226            }
227            // Free-form / wrapped: Display is already the best message.
228            _ => self.to_string(),
229        }
230    }
231}