Skip to main content

jira_cli/api/
mod.rs

1pub mod client;
2pub mod types;
3
4pub use client::JiraClient;
5pub use types::*;
6
7use std::fmt;
8
9/// Authentication method used when connecting to Jira.
10///
11/// `Basic` uses HTTP Basic auth with email and API token (Jira Cloud default).
12/// `Pat` uses a Bearer token (Personal Access Token), typically for Jira Data Center / Server.
13#[derive(Debug, Clone, PartialEq, Default)]
14pub enum AuthType {
15    #[default]
16    Basic,
17    Pat,
18}
19
20#[derive(Debug)]
21pub enum ApiError {
22    /// Bad credentials or forbidden.
23    Auth(String),
24    /// Resource not found.
25    NotFound(String),
26    /// Invalid user input (bad key format, missing required value, etc.).
27    InvalidInput(String),
28    /// HTTP 429 rate limit.
29    RateLimit,
30    /// Non-2xx response from the Jira API.
31    Api { status: u16, message: String },
32    /// Network / TLS error.
33    Http(reqwest::Error),
34    /// Any other error.
35    Other(String),
36}
37
38impl fmt::Display for ApiError {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            ApiError::Auth(msg) => write!(
42                f,
43                "Authentication failed: {msg}\nCheck JIRA_TOKEN or run `jira config show` to verify credentials."
44            ),
45            ApiError::NotFound(msg) => write!(f, "Not found: {msg}"),
46            ApiError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
47            ApiError::RateLimit => write!(f, "Rate limited by Jira. Please wait and try again."),
48            ApiError::Api { status, message } => write!(f, "API error {status}: {message}"),
49            ApiError::Http(e) => write!(f, "HTTP error: {e}"),
50            ApiError::Other(msg) => write!(f, "{msg}"),
51        }
52    }
53}
54
55impl std::error::Error for ApiError {
56    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
57        match self {
58            ApiError::Http(e) => Some(e),
59            _ => None,
60        }
61    }
62}
63
64impl From<reqwest::Error> for ApiError {
65    fn from(e: reqwest::Error) -> Self {
66        ApiError::Http(e)
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use std::error::Error;
74
75    #[test]
76    fn auth_error_display_includes_check_guidance() {
77        let err = ApiError::Auth("invalid credentials".into());
78        let msg = err.to_string();
79        assert!(msg.contains("Authentication failed"));
80        assert!(msg.contains("invalid credentials"));
81        assert!(msg.contains("JIRA_TOKEN"), "should hint at how to fix auth");
82    }
83
84    #[test]
85    fn not_found_error_display_includes_message() {
86        let err = ApiError::NotFound("PROJ-999 not found".into());
87        let msg = err.to_string();
88        assert!(msg.contains("Not found"));
89        assert!(msg.contains("PROJ-999"));
90    }
91
92    #[test]
93    fn invalid_input_error_display_includes_message() {
94        let err = ApiError::InvalidInput("host is required".into());
95        let msg = err.to_string();
96        assert!(msg.contains("Invalid input"));
97        assert!(msg.contains("host is required"));
98    }
99
100    #[test]
101    fn rate_limit_error_display_is_actionable() {
102        let err = ApiError::RateLimit;
103        let msg = err.to_string();
104        assert!(msg.to_lowercase().contains("rate limit") || msg.contains("Rate limit"));
105        assert!(msg.contains("wait"), "should tell user to wait");
106    }
107
108    #[test]
109    fn api_error_display_includes_status_and_message() {
110        let err = ApiError::Api {
111            status: 422,
112            message: "Field 'foo' is required".into(),
113        };
114        let msg = err.to_string();
115        assert!(msg.contains("422"));
116        assert!(msg.contains("Field 'foo' is required"));
117    }
118
119    #[test]
120    fn other_error_display_is_message_verbatim() {
121        let err = ApiError::Other("something unexpected".into());
122        assert_eq!(err.to_string(), "something unexpected");
123    }
124
125    #[test]
126    fn http_error_source_is_the_underlying_reqwest_error() {
127        let rt = tokio::runtime::Runtime::new().unwrap();
128        let reqwest_err = rt.block_on(async {
129            reqwest::Client::new()
130                .get("http://127.0.0.1:1")
131                .send()
132                .await
133                .unwrap_err()
134        });
135        let api_err = ApiError::Http(reqwest_err);
136        assert!(
137            api_err.source().is_some(),
138            "Http variant must expose its source"
139        );
140    }
141
142    #[test]
143    fn non_http_variants_have_no_error_source() {
144        assert!(ApiError::Auth("x".into()).source().is_none());
145        assert!(ApiError::NotFound("x".into()).source().is_none());
146        assert!(ApiError::InvalidInput("x".into()).source().is_none());
147        assert!(ApiError::RateLimit.source().is_none());
148        assert!(ApiError::Other("x".into()).source().is_none());
149    }
150}