Skip to main content

jira_cli/api/
client.rs

1use base64::Engine;
2use base64::engine::general_purpose::STANDARD as BASE64;
3use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
4use serde::de::DeserializeOwned;
5use std::collections::BTreeMap;
6
7use super::ApiError;
8use super::AuthType;
9use super::types::*;
10
11pub struct JiraClient {
12    http: reqwest::Client,
13    base_url: String,
14    agile_base_url: String,
15    site_url: String,
16    host: String,
17    api_version: u8,
18}
19
20const SEARCH_FIELDS: [&str; 7] = [
21    "summary",
22    "status",
23    "assignee",
24    "priority",
25    "issuetype",
26    "created",
27    "updated",
28];
29const SEARCH_GET_JQL_LIMIT: usize = 1500;
30
31impl JiraClient {
32    pub fn new(
33        host: &str,
34        email: &str,
35        token: &str,
36        auth_type: AuthType,
37        api_version: u8,
38    ) -> Result<Self, ApiError> {
39        // Determine the scheme. An explicit `http://` prefix is preserved as-is
40        // (useful for local testing); everything else defaults to HTTPS.
41        let (scheme, domain) = if host.starts_with("http://") {
42            (
43                "http",
44                host.trim_start_matches("http://").trim_end_matches('/'),
45            )
46        } else {
47            (
48                "https",
49                host.trim_start_matches("https://").trim_end_matches('/'),
50            )
51        };
52
53        if domain.is_empty() {
54            return Err(ApiError::Other("Host cannot be empty".into()));
55        }
56
57        let auth_value = match auth_type {
58            AuthType::Basic => {
59                let credentials = BASE64.encode(format!("{email}:{token}"));
60                format!("Basic {credentials}")
61            }
62            AuthType::Pat => format!("Bearer {token}"),
63        };
64
65        let mut headers = HeaderMap::new();
66        headers.insert(
67            AUTHORIZATION,
68            HeaderValue::from_str(&auth_value).map_err(|e| ApiError::Other(e.to_string()))?,
69        );
70
71        let http = reqwest::Client::builder()
72            .default_headers(headers)
73            .timeout(std::time::Duration::from_secs(30))
74            .build()
75            .map_err(ApiError::Http)?;
76
77        let site_url = format!("{scheme}://{domain}");
78        let base_url = format!("{site_url}/rest/api/{api_version}");
79        let agile_base_url = format!("{site_url}/rest/agile/1.0");
80
81        Ok(Self {
82            http,
83            base_url,
84            agile_base_url,
85            site_url,
86            host: domain.to_string(),
87            api_version,
88        })
89    }
90
91    pub fn host(&self) -> &str {
92        &self.host
93    }
94
95    pub fn api_version(&self) -> u8 {
96        self.api_version
97    }
98
99    pub fn browse_base_url(&self) -> &str {
100        &self.site_url
101    }
102
103    pub fn browse_url(&self, issue_key: &str) -> String {
104        format!("{}/browse/{issue_key}", self.browse_base_url())
105    }
106
107    fn map_status(status: u16, body: String) -> ApiError {
108        let message = summarize_error_body(status, &body);
109        match status {
110            401 | 403 => ApiError::Auth(message),
111            404 => ApiError::NotFound(message),
112            429 => ApiError::RateLimit,
113            _ => ApiError::Api { status, message },
114        }
115    }
116
117    async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
118        let url = format!("{}/{path}", self.base_url);
119        let resp = self.http.get(&url).send().await?;
120        let status = resp.status();
121        if !status.is_success() {
122            let body = resp.text().await.unwrap_or_default();
123            return Err(Self::map_status(status.as_u16(), body));
124        }
125        resp.json::<T>().await.map_err(ApiError::Http)
126    }
127
128    async fn agile_get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
129        let url = format!("{}/{path}", self.agile_base_url);
130        let resp = self.http.get(&url).send().await?;
131        let status = resp.status();
132        if !status.is_success() {
133            let body = resp.text().await.unwrap_or_default();
134            return Err(Self::map_status(status.as_u16(), body));
135        }
136        resp.json::<T>().await.map_err(ApiError::Http)
137    }
138
139    async fn post<T: DeserializeOwned>(
140        &self,
141        path: &str,
142        body: &serde_json::Value,
143    ) -> Result<T, ApiError> {
144        let url = format!("{}/{path}", self.base_url);
145        let resp = self.http.post(&url).json(body).send().await?;
146        let status = resp.status();
147        if !status.is_success() {
148            let body_text = resp.text().await.unwrap_or_default();
149            return Err(Self::map_status(status.as_u16(), body_text));
150        }
151        resp.json::<T>().await.map_err(ApiError::Http)
152    }
153
154    async fn post_empty_response(
155        &self,
156        path: &str,
157        body: &serde_json::Value,
158    ) -> Result<(), ApiError> {
159        let url = format!("{}/{path}", self.base_url);
160        let resp = self.http.post(&url).json(body).send().await?;
161        let status = resp.status();
162        if !status.is_success() {
163            let body_text = resp.text().await.unwrap_or_default();
164            return Err(Self::map_status(status.as_u16(), body_text));
165        }
166        Ok(())
167    }
168
169    async fn put_empty_response(
170        &self,
171        path: &str,
172        body: &serde_json::Value,
173    ) -> Result<(), ApiError> {
174        let url = format!("{}/{path}", self.base_url);
175        let resp = self.http.put(&url).json(body).send().await?;
176        let status = resp.status();
177        if !status.is_success() {
178            let body_text = resp.text().await.unwrap_or_default();
179            return Err(Self::map_status(status.as_u16(), body_text));
180        }
181        Ok(())
182    }
183
184    // ── Issues ────────────────────────────────────────────────────────────────
185
186    /// Search issues using JQL.
187    pub async fn search(
188        &self,
189        jql: &str,
190        max_results: usize,
191        start_at: usize,
192    ) -> Result<SearchResponse, ApiError> {
193        let fields = SEARCH_FIELDS.join(",");
194        let encoded_jql = percent_encode(jql);
195        if encoded_jql.len() <= SEARCH_GET_JQL_LIMIT {
196            let path = format!(
197                "search?jql={encoded_jql}&maxResults={max_results}&startAt={start_at}&fields={fields}"
198            );
199            self.get(&path).await
200        } else {
201            self.post(
202                "search",
203                &serde_json::json!({
204                    "jql": jql,
205                    "maxResults": max_results,
206                    "startAt": start_at,
207                    "fields": SEARCH_FIELDS,
208                }),
209            )
210            .await
211        }
212    }
213
214    /// Fetch a single issue by key (e.g. `PROJ-123`), including all comments.
215    ///
216    /// Jira embeds only the first page of comments in the issue response. When
217    /// the embedded page is incomplete, additional requests are made to fetch
218    /// the remaining comments.
219    pub async fn get_issue(&self, key: &str) -> Result<Issue, ApiError> {
220        validate_issue_key(key)?;
221        let fields = "summary,status,assignee,reporter,priority,issuetype,description,labels,created,updated,comment,issuelinks";
222        let path = format!("issue/{key}?fields={fields}");
223        let mut issue: Issue = self.get(&path).await?;
224
225        // Fetch remaining comment pages if the embedded page is incomplete
226        if let Some(ref mut comment_list) = issue.fields.comment
227            && comment_list.total > comment_list.comments.len()
228        {
229            let mut start_at = comment_list.comments.len();
230            while comment_list.comments.len() < comment_list.total {
231                let page: CommentList = self
232                    .get(&format!(
233                        "issue/{key}/comment?startAt={start_at}&maxResults=100"
234                    ))
235                    .await?;
236                if page.comments.is_empty() {
237                    break;
238                }
239                start_at += page.comments.len();
240                comment_list.comments.extend(page.comments);
241            }
242        }
243
244        Ok(issue)
245    }
246
247    /// Create a new issue.
248    #[allow(clippy::too_many_arguments)]
249    #[allow(clippy::too_many_arguments)]
250    pub async fn create_issue(
251        &self,
252        project_key: &str,
253        issue_type: &str,
254        summary: &str,
255        description: Option<&str>,
256        priority: Option<&str>,
257        labels: Option<&[&str]>,
258        assignee: Option<&str>,
259        parent: Option<&str>,
260        custom_fields: &[(String, serde_json::Value)],
261    ) -> Result<CreateIssueResponse, ApiError> {
262        let mut fields = serde_json::json!({
263            "project": { "key": project_key },
264            "issuetype": { "name": issue_type },
265            "summary": summary,
266        });
267
268        if let Some(desc) = description {
269            fields["description"] = self.make_body(desc);
270        }
271        if let Some(p) = priority {
272            fields["priority"] = serde_json::json!({ "name": p });
273        }
274        if let Some(lbls) = labels
275            && !lbls.is_empty()
276        {
277            fields["labels"] = serde_json::json!(lbls);
278        }
279        if let Some(id) = assignee {
280            fields["assignee"] = self.assignee_payload(id);
281        }
282        if let Some(parent_key) = parent {
283            fields["parent"] = serde_json::json!({ "key": parent_key });
284        }
285        for (key, value) in custom_fields {
286            fields[key] = value.clone();
287        }
288
289        self.post("issue", &serde_json::json!({ "fields": fields }))
290            .await
291    }
292
293    /// Log work on an issue.
294    ///
295    /// `time_spent` uses Jira duration format (e.g. `2h 30m`, `1d`, `30m`).
296    /// `started` is an ISO-8601 datetime string; when `None` the server uses now.
297    pub async fn log_work(
298        &self,
299        key: &str,
300        time_spent: &str,
301        comment: Option<&str>,
302        started: Option<&str>,
303    ) -> Result<WorklogEntry, ApiError> {
304        validate_issue_key(key)?;
305        let mut payload = serde_json::json!({ "timeSpent": time_spent });
306        if let Some(c) = comment {
307            payload["comment"] = self.make_body(c);
308        }
309        if let Some(s) = started {
310            payload["started"] = serde_json::Value::String(s.to_string());
311        }
312        self.post(&format!("issue/{key}/worklog"), &payload).await
313    }
314
315    /// Add a comment to an issue.
316    pub async fn add_comment(&self, key: &str, body: &str) -> Result<Comment, ApiError> {
317        validate_issue_key(key)?;
318        let payload = serde_json::json!({ "body": self.make_body(body) });
319        self.post(&format!("issue/{key}/comment"), &payload).await
320    }
321
322    /// List available transitions for an issue.
323    pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
324        validate_issue_key(key)?;
325        let resp: TransitionsResponse = self.get(&format!("issue/{key}/transitions")).await?;
326        Ok(resp.transitions)
327    }
328
329    /// Execute a transition by transition ID.
330    pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<(), ApiError> {
331        validate_issue_key(key)?;
332        let payload = serde_json::json!({ "transition": { "id": transition_id } });
333        self.post_empty_response(&format!("issue/{key}/transitions"), &payload)
334            .await
335    }
336
337    /// Assign an issue to a user, or unassign with `None`.
338    ///
339    /// API v3 (Jira Cloud) identifies users by `accountId`.
340    /// API v2 (Jira Data Center / Server) identifies users by `name` (username).
341    pub async fn assign_issue(&self, key: &str, account_id: Option<&str>) -> Result<(), ApiError> {
342        validate_issue_key(key)?;
343        let payload = match account_id {
344            Some(id) => self.assignee_payload(id),
345            None => {
346                if self.api_version >= 3 {
347                    serde_json::json!({ "accountId": null })
348                } else {
349                    serde_json::json!({ "name": null })
350                }
351            }
352        };
353        self.put_empty_response(&format!("issue/{key}/assignee"), &payload)
354            .await
355    }
356
357    /// Build the assignee payload for the current API version.
358    ///
359    /// API v3 uses `accountId`; API v2 uses `name` (username).
360    fn assignee_payload(&self, id: &str) -> serde_json::Value {
361        if self.api_version >= 3 {
362            serde_json::json!({ "accountId": id })
363        } else {
364            serde_json::json!({ "name": id })
365        }
366    }
367
368    /// Get the currently authenticated user.
369    pub async fn get_myself(&self) -> Result<Myself, ApiError> {
370        self.get("myself").await
371    }
372
373    /// Update issue fields (summary, description, priority, or any custom field).
374    pub async fn update_issue(
375        &self,
376        key: &str,
377        summary: Option<&str>,
378        description: Option<&str>,
379        priority: Option<&str>,
380        custom_fields: &[(String, serde_json::Value)],
381    ) -> Result<(), ApiError> {
382        validate_issue_key(key)?;
383        let mut fields = serde_json::Map::new();
384        if let Some(s) = summary {
385            fields.insert("summary".into(), serde_json::Value::String(s.into()));
386        }
387        if let Some(d) = description {
388            fields.insert("description".into(), self.make_body(d));
389        }
390        if let Some(p) = priority {
391            fields.insert("priority".into(), serde_json::json!({ "name": p }));
392        }
393        for (k, value) in custom_fields {
394            fields.insert(k.clone(), value.clone());
395        }
396        if fields.is_empty() {
397            return Err(ApiError::InvalidInput(
398                "At least one field (--summary, --description, --priority, or --field) is required"
399                    .into(),
400            ));
401        }
402        self.put_empty_response(
403            &format!("issue/{key}"),
404            &serde_json::json!({ "fields": fields }),
405        )
406        .await
407    }
408
409    /// Build the appropriate body value for a description or comment field.
410    ///
411    /// API v3 (Jira Cloud) requires Atlassian Document Format (ADF). API v2
412    /// (Jira Data Center / Server) accepts plain strings.
413    fn make_body(&self, text: &str) -> serde_json::Value {
414        if self.api_version >= 3 {
415            text_to_adf(text)
416        } else {
417            serde_json::Value::String(text.to_string())
418        }
419    }
420
421    // ── Users ─────────────────────────────────────────────────────────────────
422
423    /// Search for users matching a query string.
424    ///
425    /// API v2: uses `username` parameter. API v3: uses `query` parameter.
426    pub async fn search_users(&self, query: &str) -> Result<Vec<User>, ApiError> {
427        let encoded = percent_encode(query);
428        let param = if self.api_version >= 3 {
429            "query"
430        } else {
431            "username"
432        };
433        let path = format!("user/search?{param}={encoded}&maxResults=50");
434        self.get::<Vec<User>>(&path).await
435    }
436
437    // ── Issue links ───────────────────────────────────────────────────────────
438
439    /// List available issue link types.
440    pub async fn get_link_types(&self) -> Result<Vec<IssueLinkType>, ApiError> {
441        #[derive(serde::Deserialize)]
442        struct Wrapper {
443            #[serde(rename = "issueLinkTypes")]
444            types: Vec<IssueLinkType>,
445        }
446        let w: Wrapper = self.get("issueLinkType").await?;
447        Ok(w.types)
448    }
449
450    /// Link two issues.
451    ///
452    /// `link_type` is the name of the link type (e.g. "Blocks", "Duplicate").
453    /// The direction follows the link type's `outward` description:
454    /// `from_key` outward-links to `to_key`.
455    pub async fn link_issues(
456        &self,
457        from_key: &str,
458        to_key: &str,
459        link_type: &str,
460    ) -> Result<(), ApiError> {
461        validate_issue_key(from_key)?;
462        validate_issue_key(to_key)?;
463        let payload = serde_json::json!({
464            "type": { "name": link_type },
465            "inwardIssue": { "key": from_key },
466            "outwardIssue": { "key": to_key },
467        });
468        let url = format!("{}/issueLink", self.base_url);
469        let resp = self.http.post(&url).json(&payload).send().await?;
470        let status = resp.status();
471        if !status.is_success() {
472            let body = resp.text().await.unwrap_or_default();
473            return Err(Self::map_status(status.as_u16(), body));
474        }
475        Ok(())
476    }
477
478    /// Remove an issue link by its ID.
479    pub async fn unlink_issues(&self, link_id: &str) -> Result<(), ApiError> {
480        let url = format!("{}/issueLink/{link_id}", self.base_url);
481        let resp = self.http.delete(&url).send().await?;
482        let status = resp.status();
483        if !status.is_success() {
484            let body = resp.text().await.unwrap_or_default();
485            return Err(Self::map_status(status.as_u16(), body));
486        }
487        Ok(())
488    }
489
490    // ── Boards & Sprints ──────────────────────────────────────────────────────
491
492    /// List all boards, fetching all pages.
493    pub async fn list_boards(&self) -> Result<Vec<Board>, ApiError> {
494        let mut all = Vec::new();
495        let mut start_at = 0usize;
496        const PAGE: usize = 50;
497        loop {
498            let path = format!("board?startAt={start_at}&maxResults={PAGE}");
499            let page: BoardSearchResponse = self.agile_get(&path).await?;
500            let received = page.values.len();
501            all.extend(page.values);
502            if page.is_last || received == 0 {
503                break;
504            }
505            start_at += received;
506        }
507        Ok(all)
508    }
509
510    /// List sprints for a board, optionally filtered by state.
511    ///
512    /// `state` can be "active", "closed", "future", or `None` for all.
513    pub async fn list_sprints(
514        &self,
515        board_id: u64,
516        state: Option<&str>,
517    ) -> Result<Vec<Sprint>, ApiError> {
518        let mut all = Vec::new();
519        let mut start_at = 0usize;
520        const PAGE: usize = 50;
521        loop {
522            let state_param = state.map(|s| format!("&state={s}")).unwrap_or_default();
523            let path = format!(
524                "board/{board_id}/sprint?startAt={start_at}&maxResults={PAGE}{state_param}"
525            );
526            let page: SprintSearchResponse = self.agile_get(&path).await?;
527            let received = page.values.len();
528            all.extend(page.values);
529            if page.is_last || received == 0 {
530                break;
531            }
532            start_at += received;
533        }
534        Ok(all)
535    }
536
537    // ── Projects ──────────────────────────────────────────────────────────────
538
539    /// List all accessible projects.
540    ///
541    /// API v3 (Jira Cloud) uses the paginated `project/search` endpoint.
542    /// API v2 (Jira Data Center / Server) uses the simpler `project` endpoint
543    /// that returns all results in a single flat array.
544    pub async fn list_projects(&self) -> Result<Vec<Project>, ApiError> {
545        if self.api_version < 3 {
546            return self.get::<Vec<Project>>("project").await;
547        }
548
549        let mut all: Vec<Project> = Vec::new();
550        let mut start_at: usize = 0;
551        const PAGE: usize = 50;
552
553        loop {
554            let path = format!("project/search?startAt={start_at}&maxResults={PAGE}&orderBy=key");
555            let page: ProjectSearchResponse = self.get(&path).await?;
556            let page_start = page.start_at;
557            let received = page.values.len();
558            let total = page.total;
559            all.extend(page.values);
560
561            if page.is_last || all.len() >= total {
562                break;
563            }
564
565            if received == 0 {
566                return Err(ApiError::Other(
567                    "Project pagination returned an empty non-terminal page".into(),
568                ));
569            }
570
571            start_at = page_start.saturating_add(received);
572        }
573
574        Ok(all)
575    }
576
577    /// Fetch a single project by key.
578    pub async fn get_project(&self, key: &str) -> Result<Project, ApiError> {
579        self.get(&format!("project/{key}")).await
580    }
581
582    // ── Fields ────────────────────────────────────────────────────────────────
583
584    /// List all available fields (system and custom).
585    pub async fn list_fields(&self) -> Result<Vec<Field>, ApiError> {
586        self.get::<Vec<Field>>("field").await
587    }
588
589    /// Move an issue to a sprint.
590    ///
591    /// Uses the Agile REST API which is version-independent.
592    pub async fn move_issue_to_sprint(
593        &self,
594        issue_key: &str,
595        sprint_id: u64,
596    ) -> Result<(), ApiError> {
597        validate_issue_key(issue_key)?;
598        let url = format!("{}/sprint/{sprint_id}/issue", self.agile_base_url);
599        let payload = serde_json::json!({ "issues": [issue_key] });
600        let resp = self.http.post(&url).json(&payload).send().await?;
601        let status = resp.status();
602        if !status.is_success() {
603            let body = resp.text().await.unwrap_or_default();
604            return Err(Self::map_status(status.as_u16(), body));
605        }
606        Ok(())
607    }
608
609    /// Fetch a single sprint by numeric ID.
610    pub async fn get_sprint(&self, sprint_id: u64) -> Result<Sprint, ApiError> {
611        self.agile_get::<Sprint>(&format!("sprint/{sprint_id}"))
612            .await
613    }
614
615    /// Resolve a sprint specifier to a `Sprint`.
616    ///
617    /// Accepts:
618    /// - A numeric string: fetches the sprint by ID to confirm it exists and get the name
619    /// - `"active"`: returns the first active sprint found across all boards
620    /// - Any other string: matched case-insensitively as a substring of sprint names
621    pub async fn resolve_sprint(&self, specifier: &str) -> Result<Sprint, ApiError> {
622        if let Ok(id) = specifier.parse::<u64>() {
623            return self.get_sprint(id).await;
624        }
625
626        let boards = self.list_boards().await?;
627        if boards.is_empty() {
628            return Err(ApiError::NotFound("No boards found".into()));
629        }
630
631        let target_state = if specifier.eq_ignore_ascii_case("active") {
632            Some("active")
633        } else {
634            None
635        };
636
637        for board in &boards {
638            let sprints = self.list_sprints(board.id, target_state).await?;
639            for sprint in sprints {
640                if specifier.eq_ignore_ascii_case("active") {
641                    if sprint.state == "active" {
642                        return Ok(sprint);
643                    }
644                } else if sprint
645                    .name
646                    .to_lowercase()
647                    .contains(&specifier.to_lowercase())
648                {
649                    return Ok(sprint);
650                }
651            }
652        }
653
654        Err(ApiError::NotFound(format!(
655            "No sprint found matching '{specifier}'"
656        )))
657    }
658
659    /// Resolve a sprint specifier to its numeric ID.
660    ///
661    /// See [`resolve_sprint`] for accepted specifier formats.
662    pub async fn resolve_sprint_id(&self, specifier: &str) -> Result<u64, ApiError> {
663        if let Ok(id) = specifier.parse::<u64>() {
664            return Ok(id);
665        }
666        self.resolve_sprint(specifier).await.map(|s| s.id)
667    }
668}
669
670/// Validate that a key matches the `[A-Z][A-Z0-9]*-[0-9]+` format
671/// before using it in a URL path.
672///
673/// Jira project keys start with an uppercase letter and may contain further
674/// uppercase letters or digits (e.g. `ABC2-123` is valid).
675fn validate_issue_key(key: &str) -> Result<(), ApiError> {
676    let mut parts = key.splitn(2, '-');
677    let project = parts.next().unwrap_or("");
678    let number = parts.next().unwrap_or("");
679
680    let valid = !project.is_empty()
681        && !number.is_empty()
682        && project
683            .chars()
684            .next()
685            .is_some_and(|c| c.is_ascii_uppercase())
686        && project
687            .chars()
688            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
689        && number.chars().all(|c| c.is_ascii_digit());
690
691    if valid {
692        Ok(())
693    } else {
694        Err(ApiError::InvalidInput(format!(
695            "Invalid issue key '{key}'. Expected format: PROJECT-123"
696        )))
697    }
698}
699
700/// Percent-encode a string for use in a URL query parameter.
701///
702/// Uses `%20` for spaces (not `+`) per standard URL encoding.
703fn percent_encode(s: &str) -> String {
704    let mut encoded = String::with_capacity(s.len() * 2);
705    for byte in s.bytes() {
706        match byte {
707            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
708                encoded.push(byte as char)
709            }
710            b => encoded.push_str(&format!("%{b:02X}")),
711        }
712    }
713    encoded
714}
715
716/// Truncate an API error body when explicitly debugging HTTP failures.
717fn truncate_error_body(body: &str) -> String {
718    const MAX: usize = 200;
719    if body.chars().count() <= MAX {
720        body.to_string()
721    } else {
722        let truncated: String = body.chars().take(MAX).collect();
723        format!("{truncated}… (truncated)")
724    }
725}
726
727fn summarize_error_body(status: u16, body: &str) -> String {
728    if should_include_raw_error_body() && !body.trim().is_empty() {
729        return truncate_error_body(body);
730    }
731
732    if let Some(message) = summarize_json_error_body(body) {
733        return message;
734    }
735
736    default_status_message(status)
737}
738
739fn summarize_json_error_body(body: &str) -> Option<String> {
740    let parsed: JiraErrorPayload = serde_json::from_str(body).ok()?;
741    let mut parts = Vec::new();
742
743    if !parsed.error_messages.is_empty() {
744        parts.push(format!(
745            "{} Jira error message(s) returned",
746            parsed.error_messages.len()
747        ));
748    }
749
750    if !parsed.errors.is_empty() {
751        let fields = parsed.errors.keys().take(5).cloned().collect::<Vec<_>>();
752        parts.push(format!(
753            "validation errors for fields: {}",
754            fields.join(", ")
755        ));
756    }
757
758    if parts.is_empty() {
759        None
760    } else {
761        Some(parts.join("; "))
762    }
763}
764
765fn default_status_message(status: u16) -> String {
766    match status {
767        401 | 403 => "request unauthorized".into(),
768        404 => "resource not found".into(),
769        429 => "rate limited by Jira".into(),
770        400..=499 => format!("request failed with status {status}"),
771        _ => format!("Jira request failed with status {status}"),
772    }
773}
774
775fn should_include_raw_error_body() -> bool {
776    matches!(
777        std::env::var("JIRA_DEBUG_HTTP").ok().as_deref(),
778        Some("1" | "true" | "TRUE" | "yes" | "YES")
779    )
780}
781
782#[derive(Debug, serde::Deserialize)]
783#[serde(rename_all = "camelCase")]
784struct JiraErrorPayload {
785    #[serde(default)]
786    error_messages: Vec<String>,
787    #[serde(default)]
788    errors: BTreeMap<String, String>,
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794
795    #[test]
796    fn percent_encode_spaces_use_percent_20() {
797        assert_eq!(percent_encode("project = FOO"), "project%20%3D%20FOO");
798    }
799
800    #[test]
801    fn percent_encode_complex_jql() {
802        let jql = r#"project = "MY PROJECT""#;
803        let encoded = percent_encode(jql);
804        assert!(encoded.contains("project"));
805        assert!(!encoded.contains('"'));
806        assert!(!encoded.contains(' '));
807    }
808
809    #[test]
810    fn validate_issue_key_valid() {
811        assert!(validate_issue_key("PROJ-123").is_ok());
812        assert!(validate_issue_key("ABC-1").is_ok());
813        assert!(validate_issue_key("MYPROJECT-9999").is_ok());
814        // Digits are allowed in the project key after the initial letter
815        assert!(validate_issue_key("ABC2-123").is_ok());
816        assert!(validate_issue_key("P1-1").is_ok());
817    }
818
819    #[test]
820    fn validate_issue_key_invalid() {
821        assert!(validate_issue_key("proj-123").is_err()); // lowercase
822        assert!(validate_issue_key("PROJ123").is_err()); // no dash
823        assert!(validate_issue_key("PROJ-abc").is_err()); // non-numeric suffix
824        assert!(validate_issue_key("../etc/passwd").is_err());
825        assert!(validate_issue_key("").is_err());
826        assert!(validate_issue_key("1PROJ-123").is_err()); // starts with digit
827    }
828
829    #[test]
830    fn truncate_error_body_short() {
831        let body = "short error";
832        assert_eq!(truncate_error_body(body), body);
833    }
834
835    #[test]
836    fn truncate_error_body_long() {
837        let body = "x".repeat(300);
838        let result = truncate_error_body(&body);
839        assert!(result.len() < body.len());
840        assert!(result.ends_with("(truncated)"));
841    }
842
843    #[test]
844    fn summarize_json_error_body_redacts_values() {
845        let body = serde_json::json!({
846            "errorMessages": ["JQL validation failed"],
847            "errors": {
848                "summary": "Summary must not contain secret project name",
849                "description": "Description cannot include api token"
850            }
851        })
852        .to_string();
853
854        let message = summarize_error_body(400, &body);
855        assert!(message.contains("1 Jira error message(s) returned"));
856        assert!(message.contains("summary"));
857        assert!(message.contains("description"));
858        assert!(!message.contains("secret project name"));
859        assert!(!message.contains("api token"));
860    }
861
862    #[test]
863    fn browse_url_preserves_explicit_http_hosts() {
864        let client = JiraClient::new(
865            "http://localhost:8080",
866            "me@example.com",
867            "token",
868            AuthType::Basic,
869            3,
870        )
871        .unwrap();
872        assert_eq!(
873            client.browse_url("PROJ-1"),
874            "http://localhost:8080/browse/PROJ-1"
875        );
876    }
877
878    #[test]
879    fn new_with_pat_auth_does_not_require_email() {
880        let client = JiraClient::new(
881            "https://jira.example.com",
882            "",
883            "my-pat-token",
884            AuthType::Pat,
885            3,
886        );
887        assert!(client.is_ok());
888    }
889
890    #[test]
891    fn new_with_api_v2_uses_v2_base_url() {
892        let client = JiraClient::new(
893            "https://jira.example.com",
894            "me@example.com",
895            "token",
896            AuthType::Basic,
897            2,
898        )
899        .unwrap();
900        assert_eq!(client.api_version(), 2);
901    }
902}