1use miette::Diagnostic;
2use thiserror::Error;
3
4pub type Result<T, E = miette::Report> = std::result::Result<T, E>;
5
6#[derive(Debug, Error, Diagnostic)]
7pub enum YtError {
8 #[error("{message}")]
9 #[diagnostic(code(yt::usage))]
10 Usage { message: String },
11
12 #[error("{what} is required")]
13 #[diagnostic(code(yt::input_required), help("pass {flag}"))]
14 InputRequired { what: String, flag: String },
15
16 #[error("cancelled")]
17 #[diagnostic(code(yt::cancelled))]
18 Cancelled,
19
20 #[error("HOME is not set")]
21 #[diagnostic(
22 code(yt::no_home),
23 help("set HOME, or set YT_CONFIG_DIR, YT_CACHE_DIR and YT_STATE_DIR")
24 )]
25 NoHome,
26
27 #[error("{path}: {message}")]
28 #[diagnostic(code(yt::config))]
29 Config { path: String, message: String },
30
31 #[error("no profile is configured")]
32 #[diagnostic(
33 code(yt::no_profile),
34 help("run `yt auth login`, or set YOUTRACK_URL and YOUTRACK_TOKEN")
35 )]
36 NoProfile,
37
38 #[error("no profile uses {url}")]
39 #[diagnostic(
40 code(yt::no_profile_for_url),
41 help("{path} names this instance; if you trust it, run `yt auth login {url}`")
42 )]
43 NoProfileForUrl { url: String, path: String },
44
45 #[error("profile '{name}' does not exist")]
46 #[diagnostic(code(yt::unknown_profile))]
47 UnknownProfile {
48 name: String,
49 #[help]
50 help: Option<String>,
51 },
52
53 #[error("{count} profiles use {url}")]
54 #[diagnostic(
55 code(yt::ambiguous_profile),
56 help("pick one with --profile, or run `yt profile use <name>`")
57 )]
58 AmbiguousProfile { url: String, count: usize },
59
60 #[error("not signed in to {profile}")]
61 #[diagnostic(
62 code(yt::not_signed_in),
63 help("run `yt auth login --profile {profile}`")
64 )]
65 NotSignedIn { profile: String },
66
67 #[error("{profile}: the server rejected the credential")]
68 #[diagnostic(
69 code(yt::credential_rejected),
70 help("run `yt auth login --profile {profile}`, or check YOUTRACK_TOKEN")
71 )]
72 CredentialRejected { profile: String },
73
74 #[error("the server rejected the token")]
75 #[diagnostic(
76 code(yt::token_rejected),
77 help("the token must include the YouTrack scope")
78 )]
79 TokenRejected,
80
81 #[error("your sign-in to {profile} has expired")]
82 #[diagnostic(
83 code(yt::session_expired),
84 help("run `yt auth login --profile {profile}`")
85 )]
86 SessionExpired { profile: String },
87
88 #[error("no system keyring is available ({reason})")]
89 #[diagnostic(
90 code(yt::no_keyring),
91 help("set YOUTRACK_URL and YOUTRACK_TOKEN instead")
92 )]
93 NoKeyring { reason: String },
94
95 #[error("keyring: {0}")]
96 #[diagnostic(code(yt::keyring))]
97 Keyring(String),
98
99 #[error("{url} is not a reachable YouTrack instance: {reason}")]
100 #[diagnostic(
101 code(yt::discovery),
102 help("use the address you open YouTrack at, e.g. https://acme.youtrack.cloud")
103 )]
104 Discovery { url: String, reason: String },
105
106 #[error("sign-in failed: {message}")]
107 #[diagnostic(code(yt::oauth))]
108 OAuth {
109 message: String,
110 #[help]
111 help: Option<String>,
112 },
113
114 #[error("{method} {path}: {message}")]
115 #[diagnostic(code(yt::api))]
116 Api {
117 method: String,
118 path: String,
119 status: u16,
120 message: String,
121 #[help]
122 help: Option<String>,
123 },
124
125 #[error("network error: {0}")]
126 #[diagnostic(
127 code(yt::network),
128 help("check your network connection and the instance URL")
129 )]
130 Network(String),
131}
132
133pub fn printable(report: &miette::Report) -> miette::Report {
135 miette::Report::new(Printable::of(report.as_ref()))
136}
137
138#[derive(Debug)]
139struct Printable {
140 message: String,
141 code: Option<String>,
142 help: Option<String>,
143 url: Option<String>,
144 severity: Option<miette::Severity>,
145 source: Option<Box<Printable>>,
146}
147
148impl Printable {
149 fn of(diagnostic: &dyn miette::Diagnostic) -> Self {
150 Self {
151 message: text(&diagnostic),
152 code: diagnostic.code().map(|code| text(&code)),
153 help: diagnostic.help().map(|help| text(&help)),
154 url: diagnostic.url().map(|url| text(&url)),
155 severity: diagnostic.severity(),
156 source: diagnostic.source().map(Self::cause),
157 }
158 }
159
160 fn cause(error: &dyn std::error::Error) -> Box<Self> {
161 Box::new(Self {
162 message: text(&error),
163 code: None,
164 help: None,
165 url: None,
166 severity: None,
167 source: error.source().map(Self::cause),
168 })
169 }
170}
171
172fn text(value: &dyn std::fmt::Display) -> String {
173 crate::output::printable(&value.to_string()).into_owned()
174}
175
176impl std::fmt::Display for Printable {
177 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178 f.write_str(&self.message)
179 }
180}
181
182impl std::error::Error for Printable {
183 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
184 self.source
185 .as_deref()
186 .map(|source| source as &(dyn std::error::Error + 'static))
187 }
188}
189
190impl miette::Diagnostic for Printable {
191 fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
192 boxed(self.code.as_ref())
193 }
194
195 fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
196 boxed(self.help.as_ref())
197 }
198
199 fn url<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
200 boxed(self.url.as_ref())
201 }
202
203 fn severity(&self) -> Option<miette::Severity> {
204 self.severity
205 }
206}
207
208fn boxed<'a>(text: Option<&'a String>) -> Option<Box<dyn std::fmt::Display + 'a>> {
209 text.map(|text| Box::new(text) as Box<dyn std::fmt::Display + 'a>)
210}
211
212pub fn exit_code(report: &miette::Report) -> u8 {
213 match report.downcast_ref::<YtError>() {
214 Some(YtError::Usage { .. } | YtError::InputRequired { .. }) => 2,
215 Some(
216 YtError::NotSignedIn { .. }
217 | YtError::CredentialRejected { .. }
218 | YtError::TokenRejected
219 | YtError::SessionExpired { .. },
220 ) => 4,
221 Some(YtError::Cancelled) => 130,
222 _ => 1,
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 fn code(error: YtError) -> u8 {
231 exit_code(&miette::Report::new(error))
232 }
233
234 #[test]
235 fn exit_codes_follow_the_spec() {
236 assert_eq!(
237 code(YtError::Usage {
238 message: "x".into()
239 }),
240 2
241 );
242 assert_eq!(
243 code(YtError::InputRequired {
244 what: "URL".into(),
245 flag: "<URL>".into()
246 }),
247 2
248 );
249 assert_eq!(
250 code(YtError::NotSignedIn {
251 profile: "acme".into()
252 }),
253 4
254 );
255 assert_eq!(
256 code(YtError::CredentialRejected {
257 profile: "acme".into()
258 }),
259 4
260 );
261 assert_eq!(code(YtError::TokenRejected), 4);
262 assert_eq!(
263 code(YtError::SessionExpired {
264 profile: "acme".into()
265 }),
266 4
267 );
268 assert_eq!(code(YtError::Cancelled), 130);
269 assert_eq!(code(YtError::NoProfile), 1);
270 }
271
272 fn plain(diagnostic: &dyn miette::Diagnostic) -> String {
273 let mut text = String::new();
274 miette::GraphicalReportHandler::new_themed(miette::GraphicalTheme::unicode_nocolor())
275 .render_report(&mut text, diagnostic)
276 .unwrap();
277 text
278 }
279
280 #[test]
281 fn printable_reports_keep_the_text_without_control_characters() {
282 let report = miette::Report::new(YtError::Api {
283 method: "GET".into(),
284 path: "/api/issues".into(),
285 status: 400,
286 message: "bad\u{1b}[2J query".into(),
287 help: Some("try\u{7} again".into()),
288 });
289 let wrapped = miette::Report::new(YtError::Api {
290 method: "GET".into(),
291 path: "/api/issues".into(),
292 status: 400,
293 message: "inner\u{9b}1A".into(),
294 help: None,
295 })
296 .wrap_err("while \u{1b}]0;t\u{7}reading");
297 for report in [report, wrapped] {
298 let text = plain(printable(&report).as_ref());
299 assert_eq!(text, crate::output::printable(&plain(report.as_ref())));
300 assert!(
301 !text.chars().any(|c| c.is_control() && c != '\n'),
302 "{text:?}"
303 );
304 }
305 }
306
307 #[test]
308 fn exit_code_survives_added_context() {
309 let report = miette::Report::new(YtError::Cancelled).wrap_err("while signing in");
310 assert_eq!(exit_code(&report), 130);
311 }
312
313 #[test]
314 fn help_names_the_profile() {
315 let report = miette::Report::new(YtError::NotSignedIn {
316 profile: "acme".into(),
317 });
318 assert!(format!("{report:?}").contains("yt auth login --profile acme"));
319 }
320}