Skip to main content

jira_cli/api/
types.rs

1use serde::{Deserialize, Serialize};
2
3/// Jira issue as returned by the search and issue endpoints.
4#[derive(Debug, Deserialize, Serialize, Clone)]
5pub struct Issue {
6    pub id: String,
7    pub key: String,
8    #[serde(rename = "self")]
9    pub url: Option<String>,
10    pub fields: IssueFields,
11}
12
13impl Issue {
14    pub fn summary(&self) -> &str {
15        &self.fields.summary
16    }
17
18    pub fn status(&self) -> &str {
19        &self.fields.status.name
20    }
21
22    pub fn assignee(&self) -> &str {
23        self.fields
24            .assignee
25            .as_ref()
26            .map(|a| a.display_name.as_str())
27            .unwrap_or("-")
28    }
29
30    pub fn priority(&self) -> &str {
31        self.fields
32            .priority
33            .as_ref()
34            .map(|p| p.name.as_str())
35            .unwrap_or("-")
36    }
37
38    pub fn issue_type(&self) -> &str {
39        &self.fields.issuetype.name
40    }
41
42    /// Extract plain text from the Atlassian Document Format description.
43    pub fn description_text(&self) -> String {
44        match &self.fields.description {
45            Some(doc) => extract_adf_text(doc),
46            None => String::new(),
47        }
48    }
49
50    /// Construct the browser URL from the site base URL.
51    pub fn browser_url(&self, site_url: &str) -> String {
52        format!("{site_url}/browse/{}", self.key)
53    }
54}
55
56#[derive(Debug, Deserialize, Serialize, Clone)]
57pub struct IssueFields {
58    pub summary: String,
59    pub status: StatusField,
60    pub assignee: Option<UserField>,
61    pub reporter: Option<UserField>,
62    pub priority: Option<PriorityField>,
63    pub issuetype: IssueTypeField,
64    pub description: Option<serde_json::Value>,
65    pub labels: Option<Vec<String>>,
66    pub created: Option<String>,
67    pub updated: Option<String>,
68    pub comment: Option<CommentList>,
69}
70
71#[derive(Debug, Deserialize, Serialize, Clone)]
72pub struct StatusField {
73    pub name: String,
74}
75
76#[derive(Debug, Deserialize, Serialize, Clone)]
77#[serde(rename_all = "camelCase")]
78pub struct UserField {
79    pub display_name: String,
80    pub email_address: Option<String>,
81    pub account_id: Option<String>,
82}
83
84#[derive(Debug, Deserialize, Serialize, Clone)]
85pub struct PriorityField {
86    pub name: String,
87}
88
89#[derive(Debug, Deserialize, Serialize, Clone)]
90pub struct IssueTypeField {
91    pub name: String,
92}
93
94#[derive(Debug, Deserialize, Serialize, Clone)]
95#[serde(rename_all = "camelCase")]
96pub struct CommentList {
97    pub comments: Vec<Comment>,
98    pub total: usize,
99    #[serde(default)]
100    pub start_at: usize,
101    #[serde(default)]
102    pub max_results: usize,
103}
104
105#[derive(Debug, Deserialize, Serialize, Clone)]
106#[serde(rename_all = "camelCase")]
107pub struct Comment {
108    pub id: String,
109    pub author: UserField,
110    pub body: Option<serde_json::Value>,
111    pub created: String,
112    pub updated: Option<String>,
113}
114
115impl Comment {
116    pub fn body_text(&self) -> String {
117        match &self.body {
118            Some(doc) => extract_adf_text(doc),
119            None => String::new(),
120        }
121    }
122}
123
124/// Jira project.
125#[derive(Debug, Deserialize, Serialize, Clone)]
126pub struct Project {
127    pub id: String,
128    pub key: String,
129    pub name: String,
130    #[serde(rename = "projectTypeKey")]
131    pub project_type: Option<String>,
132}
133
134/// Response from the paginated project search endpoint.
135#[derive(Debug, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct ProjectSearchResponse {
138    pub values: Vec<Project>,
139    pub total: usize,
140    #[serde(default)]
141    pub start_at: usize,
142    #[serde(default)]
143    pub max_results: usize,
144    pub is_last: bool,
145}
146
147/// A single issue transition (workflow action).
148#[derive(Debug, Deserialize, Serialize, Clone)]
149pub struct Transition {
150    pub id: String,
151    pub name: String,
152    /// The status this transition leads to, including its workflow category.
153    pub to: Option<TransitionTo>,
154}
155
156/// The target status of a transition.
157#[derive(Debug, Deserialize, Serialize, Clone)]
158#[serde(rename_all = "camelCase")]
159pub struct TransitionTo {
160    pub name: String,
161    pub status_category: Option<StatusCategory>,
162}
163
164/// Workflow category for a status (e.g. "new", "indeterminate", "done").
165#[derive(Debug, Deserialize, Serialize, Clone)]
166pub struct StatusCategory {
167    pub key: String,
168    pub name: String,
169}
170
171/// Response from the Jira search endpoint.
172#[derive(Debug, Deserialize, Serialize)]
173pub struct SearchResponse {
174    pub issues: Vec<Issue>,
175    pub total: usize,
176    #[serde(rename = "startAt")]
177    pub start_at: usize,
178    #[serde(rename = "maxResults")]
179    pub max_results: usize,
180}
181
182/// Response from the transitions endpoint.
183#[derive(Debug, Deserialize, Serialize)]
184pub struct TransitionsResponse {
185    pub transitions: Vec<Transition>,
186}
187
188/// Response from creating an issue.
189#[derive(Debug, Deserialize, Serialize)]
190pub struct CreateIssueResponse {
191    pub id: String,
192    pub key: String,
193    #[serde(rename = "self")]
194    pub url: String,
195}
196
197/// Current authenticated user.
198#[derive(Debug, Deserialize)]
199#[serde(rename_all = "camelCase")]
200pub struct Myself {
201    pub account_id: String,
202    pub display_name: String,
203}
204
205/// Build an Atlassian Document Format document from plain text.
206///
207/// Each newline-separated line becomes a separate ADF paragraph node.
208/// Blank lines produce empty paragraphs (no content array items), which is the
209/// correct ADF representation accepted by Jira Cloud.
210pub fn text_to_adf(text: &str) -> serde_json::Value {
211    let paragraphs: Vec<serde_json::Value> = text
212        .split('\n')
213        .map(|line| {
214            if line.is_empty() {
215                serde_json::json!({ "type": "paragraph", "content": [] })
216            } else {
217                serde_json::json!({
218                    "type": "paragraph",
219                    "content": [{"type": "text", "text": line}]
220                })
221            }
222        })
223        .collect();
224
225    serde_json::json!({
226        "type": "doc",
227        "version": 1,
228        "content": paragraphs
229    })
230}
231
232/// Extract plain text from an Atlassian Document Format node.
233pub fn extract_adf_text(node: &serde_json::Value) -> String {
234    let mut buf = String::new();
235    collect_text(node, &mut buf);
236    buf.trim().to_string()
237}
238
239fn collect_text(node: &serde_json::Value, buf: &mut String) {
240    let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
241
242    if node_type == "text" {
243        if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
244            buf.push_str(text);
245        }
246        return;
247    }
248
249    if node_type == "hardBreak" {
250        buf.push('\n');
251        return;
252    }
253
254    if let Some(content) = node.get("content").and_then(|v| v.as_array()) {
255        for child in content {
256            collect_text(child, buf);
257        }
258    }
259
260    // Block-level nodes get a trailing newline
261    if matches!(
262        node_type,
263        "paragraph"
264            | "heading"
265            | "bulletList"
266            | "orderedList"
267            | "listItem"
268            | "codeBlock"
269            | "blockquote"
270            | "rule"
271    ) && !buf.ends_with('\n')
272    {
273        buf.push('\n');
274    }
275}
276
277/// Escape a value for use inside a JQL double-quoted string literal.
278///
279/// JQL escapes double quotes as `\"` inside a quoted string.
280pub fn escape_jql(value: &str) -> String {
281    value.replace('\\', "\\\\").replace('"', "\\\"")
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn extract_simple_paragraph() {
290        let doc = serde_json::json!({
291            "type": "doc",
292            "version": 1,
293            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
294        });
295        assert_eq!(extract_adf_text(&doc), "Hello world");
296    }
297
298    #[test]
299    fn extract_multiple_paragraphs() {
300        let doc = serde_json::json!({
301            "type": "doc",
302            "version": 1,
303            "content": [
304                {"type": "paragraph", "content": [{"type": "text", "text": "First"}]},
305                {"type": "paragraph", "content": [{"type": "text", "text": "Second"}]}
306            ]
307        });
308        let text = extract_adf_text(&doc);
309        assert!(text.contains("First"));
310        assert!(text.contains("Second"));
311    }
312
313    #[test]
314    fn text_to_adf_preserves_newlines() {
315        let original = "Line one\nLine two\nLine three";
316        let adf = text_to_adf(original);
317        let extracted = extract_adf_text(&adf);
318        assert!(extracted.contains("Line one"));
319        assert!(extracted.contains("Line two"));
320        assert!(extracted.contains("Line three"));
321    }
322
323    #[test]
324    fn text_to_adf_single_line_roundtrip() {
325        let original = "My description text";
326        let adf = text_to_adf(original);
327        let extracted = extract_adf_text(&adf);
328        assert_eq!(extracted, original);
329    }
330
331    #[test]
332    fn text_to_adf_blank_line_produces_empty_paragraph() {
333        let adf = text_to_adf("First\n\nThird");
334        let content = adf["content"].as_array().unwrap();
335        assert_eq!(content.len(), 3);
336        // The blank middle line must produce an empty content array, not a text node
337        // with an empty string — the latter is rejected by some Jira Cloud instances.
338        let blank_paragraph = &content[1];
339        assert_eq!(blank_paragraph["type"], "paragraph");
340        let blank_content = blank_paragraph["content"].as_array().unwrap();
341        assert!(blank_content.is_empty());
342    }
343
344    #[test]
345    fn escape_jql_double_quotes() {
346        assert_eq!(escape_jql(r#"say "hello""#), r#"say \"hello\""#);
347    }
348
349    #[test]
350    fn escape_jql_clean_input() {
351        assert_eq!(escape_jql("In Progress"), "In Progress");
352    }
353
354    #[test]
355    fn escape_jql_backslash() {
356        assert_eq!(escape_jql(r"foo\bar"), r"foo\\bar");
357    }
358}