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 Api { status: u16, message: String },
41 Http(reqwest::Error),
43 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 #[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}