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    /// A destructive operation was refused because it was not explicitly
29    /// confirmed. Distinct from `InvalidInput` because the command line was
30    /// well formed: adding `--yes` makes the identical request succeed.
31    ConfirmationRequired(String),
32    /// HTTP 429 rate limit.
33    RateLimit,
34    /// The request conflicts with the current state of something it would
35    /// change: an HTTP 409 from Jira, or a local file the command would
36    /// overwrite. Retrying unchanged reproduces the conflict, so the caller has
37    /// to resolve it first.
38    Conflict(String),
39    /// Non-2xx response from the Jira API.
40    Api { status: u16, message: String },
41    /// Network / TLS error.
42    Http(reqwest::Error),
43    /// Any other error.
44    Other(String),
45}
46
47impl fmt::Display for ApiError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            ApiError::Auth(msg) => write!(
51                f,
52                "Authentication failed: {msg}\nCheck JIRA_TOKEN or run `jira config show` to verify credentials."
53            ),
54            ApiError::NotFound(msg) => write!(f, "Not found: {msg}"),
55            ApiError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
56            ApiError::ConfirmationRequired(msg) => write!(f, "Confirmation required: {msg}"),
57            ApiError::RateLimit => write!(f, "Rate limited by Jira. Please wait and try again."),
58            ApiError::Conflict(msg) => write!(f, "Conflict: {msg}"),
59            ApiError::Api { status, message } => write!(f, "API error {status}: {message}"),
60            ApiError::Http(e) => write!(f, "HTTP error: {e}"),
61            ApiError::Other(msg) => write!(f, "{msg}"),
62        }
63    }
64}
65
66impl std::error::Error for ApiError {
67    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
68        match self {
69            ApiError::Http(e) => Some(e),
70            _ => None,
71        }
72    }
73}
74
75impl From<reqwest::Error> for ApiError {
76    fn from(e: reqwest::Error) -> Self {
77        ApiError::Http(e)
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use std::error::Error;
85
86    #[test]
87    fn auth_error_display_includes_check_guidance() {
88        let err = ApiError::Auth("invalid credentials".into());
89        let msg = err.to_string();
90        assert!(msg.contains("Authentication failed"));
91        assert!(msg.contains("invalid credentials"));
92        assert!(msg.contains("JIRA_TOKEN"), "should hint at how to fix auth");
93    }
94
95    #[test]
96    fn not_found_error_display_includes_message() {
97        let err = ApiError::NotFound("PROJ-999 not found".into());
98        let msg = err.to_string();
99        assert!(msg.contains("Not found"));
100        assert!(msg.contains("PROJ-999"));
101    }
102
103    #[test]
104    fn invalid_input_error_display_includes_message() {
105        let err = ApiError::InvalidInput("host is required".into());
106        let msg = err.to_string();
107        assert!(msg.contains("Invalid input"));
108        assert!(msg.contains("host is required"));
109    }
110
111    #[test]
112    fn rate_limit_error_display_is_actionable() {
113        let err = ApiError::RateLimit;
114        let msg = err.to_string();
115        assert!(msg.to_lowercase().contains("rate limit") || msg.contains("Rate limit"));
116        assert!(msg.contains("wait"), "should tell user to wait");
117    }
118
119    #[test]
120    fn api_error_display_includes_status_and_message() {
121        let err = ApiError::Api {
122            status: 422,
123            message: "Field 'foo' is required".into(),
124        };
125        let msg = err.to_string();
126        assert!(msg.contains("422"));
127        assert!(msg.contains("Field 'foo' is required"));
128    }
129
130    #[test]
131    fn other_error_display_is_message_verbatim() {
132        let err = ApiError::Other("something unexpected".into());
133        assert_eq!(err.to_string(), "something unexpected");
134    }
135
136    #[test]
137    fn http_error_source_is_the_underlying_reqwest_error() {
138        let rt = tokio::runtime::Runtime::new().unwrap();
139        let reqwest_err = rt.block_on(async {
140            reqwest::Client::new()
141                .get("http://127.0.0.1:1")
142                .send()
143                .await
144                .unwrap_err()
145        });
146        let api_err = ApiError::Http(reqwest_err);
147        assert!(
148            api_err.source().is_some(),
149            "Http variant must expose its source"
150        );
151    }
152
153    #[test]
154    fn non_http_variants_have_no_error_source() {
155        assert!(ApiError::Auth("x".into()).source().is_none());
156        assert!(ApiError::NotFound("x".into()).source().is_none());
157        assert!(ApiError::InvalidInput("x".into()).source().is_none());
158        assert!(
159            ApiError::ConfirmationRequired("x".into())
160                .source()
161                .is_none()
162        );
163        assert!(ApiError::RateLimit.source().is_none());
164        assert!(ApiError::Conflict("x".into()).source().is_none());
165        assert!(ApiError::Other("x".into()).source().is_none());
166    }
167
168    #[test]
169    fn conflict_error_display_includes_message() {
170        let err = ApiError::Conflict("issue was edited by someone else".into());
171        let msg = err.to_string();
172        assert!(msg.contains("Conflict"));
173        assert!(msg.contains("issue was edited by someone else"));
174    }
175
176    /// The refusal names the flag that resolves it, so the caller does not have
177    /// to guess how to proceed.
178    #[test]
179    fn confirmation_required_display_names_the_remedy() {
180        let err = ApiError::ConfirmationRequired("bulk-assign requires --yes".into());
181        let msg = err.to_string();
182        assert!(msg.contains("Confirmation required"));
183        assert!(msg.contains("--yes"));
184    }
185}