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    /// An issue was created, but a subsequent sprint move failed.
40    PartialSuccess {
41        key: String,
42        url: String,
43        sprint_id: u64,
44        source: Box<ApiError>,
45    },
46    /// A bulk command completed with one or more failed items. Its complete
47    /// per-issue summary is emitted on stdout, including on failure.
48    BulkFailure {
49        total: usize,
50        succeeded: usize,
51        failed: usize,
52        not_attempted: usize,
53    },
54    /// Structured context without changing the underlying failure contract.
55    WithDetails {
56        source: Box<ApiError>,
57        details: serde_json::Value,
58    },
59    /// Non-2xx response from the Jira API.
60    Api { status: u16, message: String },
61    /// Network / TLS error.
62    Http(reqwest::Error),
63    /// Any other error.
64    Other(String),
65}
66
67impl fmt::Display for ApiError {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            ApiError::Auth(msg) => write!(
71                f,
72                "Authentication failed: {msg}\nCheck JIRA_TOKEN or run `jira config show` to verify credentials."
73            ),
74            ApiError::NotFound(msg) => write!(f, "Not found: {msg}"),
75            ApiError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
76            ApiError::ConfirmationRequired(msg) => write!(f, "Confirmation required: {msg}"),
77            ApiError::RateLimit => write!(f, "Rate limited by Jira. Please wait and try again."),
78            ApiError::Conflict(msg) => write!(f, "Conflict: {msg}"),
79            ApiError::PartialSuccess {
80                key,
81                url,
82                sprint_id,
83                source,
84            } => write!(
85                f,
86                "Created {key} ({url}), but adding it to sprint {sprint_id} failed: {source}. Retry only `jira issues move {key} --sprint {sprint_id}`; do not rerun issues create."
87            ),
88            ApiError::Api { status, message } => write!(f, "API error {status}: {message}"),
89            ApiError::BulkFailure {
90                total,
91                succeeded,
92                failed,
93                not_attempted,
94            } => write!(
95                f,
96                "Bulk operation: {succeeded} succeeded, {failed} failed, {not_attempted} not attempted out of {total}. Inspect the per-issue results; do not retry the whole command."
97            ),
98            ApiError::WithDetails { source, .. } => source.fmt(f),
99            ApiError::Http(e) => write!(f, "HTTP error: {e}"),
100            ApiError::Other(msg) => write!(f, "{msg}"),
101        }
102    }
103}
104
105impl std::error::Error for ApiError {
106    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
107        match self {
108            ApiError::Http(e) => Some(e),
109            ApiError::PartialSuccess { source, .. } => Some(source.as_ref()),
110            ApiError::WithDetails { source, .. } => Some(source.as_ref()),
111            _ => None,
112        }
113    }
114}
115
116impl From<reqwest::Error> for ApiError {
117    fn from(e: reqwest::Error) -> Self {
118        ApiError::Http(e)
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use std::error::Error;
126
127    #[test]
128    fn auth_error_display_includes_check_guidance() {
129        let err = ApiError::Auth("invalid credentials".into());
130        let msg = err.to_string();
131        assert!(msg.contains("Authentication failed"));
132        assert!(msg.contains("invalid credentials"));
133        assert!(msg.contains("JIRA_TOKEN"), "should hint at how to fix auth");
134    }
135
136    #[test]
137    fn not_found_error_display_includes_message() {
138        let err = ApiError::NotFound("PROJ-999 not found".into());
139        let msg = err.to_string();
140        assert!(msg.contains("Not found"));
141        assert!(msg.contains("PROJ-999"));
142    }
143
144    #[test]
145    fn invalid_input_error_display_includes_message() {
146        let err = ApiError::InvalidInput("host is required".into());
147        let msg = err.to_string();
148        assert!(msg.contains("Invalid input"));
149        assert!(msg.contains("host is required"));
150    }
151
152    #[test]
153    fn rate_limit_error_display_is_actionable() {
154        let err = ApiError::RateLimit;
155        let msg = err.to_string();
156        assert!(msg.to_lowercase().contains("rate limit") || msg.contains("Rate limit"));
157        assert!(msg.contains("wait"), "should tell user to wait");
158    }
159
160    #[test]
161    fn api_error_display_includes_status_and_message() {
162        let err = ApiError::Api {
163            status: 422,
164            message: "Field 'foo' is required".into(),
165        };
166        let msg = err.to_string();
167        assert!(msg.contains("422"));
168        assert!(msg.contains("Field 'foo' is required"));
169    }
170
171    #[test]
172    fn other_error_display_is_message_verbatim() {
173        let err = ApiError::Other("something unexpected".into());
174        assert_eq!(err.to_string(), "something unexpected");
175    }
176
177    #[test]
178    fn http_error_source_is_the_underlying_reqwest_error() {
179        let rt = tokio::runtime::Runtime::new().unwrap();
180        let reqwest_err = rt.block_on(async {
181            reqwest::Client::new()
182                .get("http://127.0.0.1:1")
183                .send()
184                .await
185                .unwrap_err()
186        });
187        let api_err = ApiError::Http(reqwest_err);
188        assert!(
189            api_err.source().is_some(),
190            "Http variant must expose its source"
191        );
192    }
193
194    #[test]
195    fn non_http_variants_have_no_error_source() {
196        assert!(ApiError::Auth("x".into()).source().is_none());
197        assert!(ApiError::NotFound("x".into()).source().is_none());
198        assert!(ApiError::InvalidInput("x".into()).source().is_none());
199        assert!(
200            ApiError::ConfirmationRequired("x".into())
201                .source()
202                .is_none()
203        );
204        assert!(ApiError::RateLimit.source().is_none());
205        assert!(ApiError::Conflict("x".into()).source().is_none());
206        assert!(ApiError::Other("x".into()).source().is_none());
207    }
208
209    #[test]
210    fn conflict_error_display_includes_message() {
211        let err = ApiError::Conflict("issue was edited by someone else".into());
212        let msg = err.to_string();
213        assert!(msg.contains("Conflict"));
214        assert!(msg.contains("issue was edited by someone else"));
215    }
216
217    /// The refusal names the flag that resolves it, so the caller does not have
218    /// to guess how to proceed.
219    #[test]
220    fn confirmation_required_display_names_the_remedy() {
221        let err = ApiError::ConfirmationRequired("bulk-assign requires --yes".into());
222        let msg = err.to_string();
223        assert!(msg.contains("Confirmation required"));
224        assert!(msg.contains("--yes"));
225    }
226}