Skip to main content

yt/
error.rs

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
133/// A copy of `report` for display, with terminal control characters replaced in every text.
134pub 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}