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 RateLimit,
30 Api { status: u16, message: String },
32 Http(reqwest::Error),
34 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}