1pub mod client;
2pub mod types;
3
4pub use client::JiraClient;
5pub use types::*;
6
7use std::fmt;
8
9#[derive(Debug, Clone, PartialEq, Default)]
14pub enum AuthType {
15 #[default]
16 Basic,
17 Pat,
18}
19
20#[derive(Debug)]
21pub enum ApiError {
22 Auth(String),
24 NotFound(String),
26 InvalidInput(String),
28 ConfirmationRequired(String),
32 RateLimit,
34 Conflict(String),
39 PartialSuccess {
41 key: String,
42 url: String,
43 sprint_id: u64,
44 source: Box<ApiError>,
45 },
46 BulkFailure {
49 total: usize,
50 succeeded: usize,
51 failed: usize,
52 not_attempted: usize,
53 },
54 WithDetails {
56 source: Box<ApiError>,
57 details: serde_json::Value,
58 },
59 Api { status: u16, message: String },
61 Http(reqwest::Error),
63 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 #[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}