1use 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 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 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 _ => self.to_string(),
229 }
230 }
231}