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 host.
51    pub fn browser_url(&self, host: &str) -> String {
52        format!("https://{host}/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    pub is_last: bool,
141}
142
143/// A single issue transition (workflow action).
144#[derive(Debug, Deserialize, Serialize, Clone)]
145pub struct Transition {
146    pub id: String,
147    pub name: String,
148    /// The status this transition leads to, including its workflow category.
149    pub to: Option<TransitionTo>,
150}
151
152/// The target status of a transition.
153#[derive(Debug, Deserialize, Serialize, Clone)]
154#[serde(rename_all = "camelCase")]
155pub struct TransitionTo {
156    pub name: String,
157    pub status_category: Option<StatusCategory>,
158}
159
160/// Workflow category for a status (e.g. "new", "indeterminate", "done").
161#[derive(Debug, Deserialize, Serialize, Clone)]
162pub struct StatusCategory {
163    pub key: String,
164    pub name: String,
165}
166
167/// Response from the Jira search endpoint.
168#[derive(Debug, Deserialize, Serialize)]
169pub struct SearchResponse {
170    pub issues: Vec<Issue>,
171    pub total: usize,
172    #[serde(rename = "startAt")]
173    pub start_at: usize,
174    #[serde(rename = "maxResults")]
175    pub max_results: usize,
176}
177
178/// Response from the transitions endpoint.
179#[derive(Debug, Deserialize, Serialize)]
180pub struct TransitionsResponse {
181    pub transitions: Vec<Transition>,
182}
183
184/// Response from creating an issue.
185#[derive(Debug, Deserialize, Serialize)]
186pub struct CreateIssueResponse {
187    pub id: String,
188    pub key: String,
189    #[serde(rename = "self")]
190    pub url: String,
191}
192
193/// Current authenticated user.
194#[derive(Debug, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct Myself {
197    pub account_id: String,
198    pub display_name: String,
199}
200
201/// Build an Atlassian Document Format document from plain text.
202///
203/// Each newline-separated line becomes a separate ADF paragraph node.
204/// Blank lines produce empty paragraphs (no content array items), which is the
205/// correct ADF representation accepted by Jira Cloud.
206pub fn text_to_adf(text: &str) -> serde_json::Value {
207    let paragraphs: Vec<serde_json::Value> = text
208        .split('\n')
209        .map(|line| {
210            if line.is_empty() {
211                serde_json::json!({ "type": "paragraph", "content": [] })
212            } else {
213                serde_json::json!({
214                    "type": "paragraph",
215                    "content": [{"type": "text", "text": line}]
216                })
217            }
218        })
219        .collect();
220
221    serde_json::json!({
222        "type": "doc",
223        "version": 1,
224        "content": paragraphs
225    })
226}
227
228/// Extract plain text from an Atlassian Document Format node.
229pub fn extract_adf_text(node: &serde_json::Value) -> String {
230    let mut buf = String::new();
231    collect_text(node, &mut buf);
232    buf.trim().to_string()
233}
234
235fn collect_text(node: &serde_json::Value, buf: &mut String) {
236    let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
237
238    if node_type == "text" {
239        if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
240            buf.push_str(text);
241        }
242        return;
243    }
244
245    if node_type == "hardBreak" {
246        buf.push('\n');
247        return;
248    }
249
250    if let Some(content) = node.get("content").and_then(|v| v.as_array()) {
251        for child in content {
252            collect_text(child, buf);
253        }
254    }
255
256    // Block-level nodes get a trailing newline
257    if matches!(
258        node_type,
259        "paragraph" | "heading" | "bulletList" | "orderedList" | "listItem" | "codeBlock" | "blockquote" | "rule"
260    ) && !buf.ends_with('\n')
261    {
262        buf.push('\n');
263    }
264}
265
266/// Escape a value for use inside a JQL double-quoted string literal.
267///
268/// JQL escapes double quotes as `\"` inside a quoted string.
269pub fn escape_jql(value: &str) -> String {
270    value.replace('\\', "\\\\").replace('"', "\\\"")
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn extract_simple_paragraph() {
279        let doc = serde_json::json!({
280            "type": "doc",
281            "version": 1,
282            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
283        });
284        assert_eq!(extract_adf_text(&doc), "Hello world");
285    }
286
287    #[test]
288    fn extract_multiple_paragraphs() {
289        let doc = serde_json::json!({
290            "type": "doc",
291            "version": 1,
292            "content": [
293                {"type": "paragraph", "content": [{"type": "text", "text": "First"}]},
294                {"type": "paragraph", "content": [{"type": "text", "text": "Second"}]}
295            ]
296        });
297        let text = extract_adf_text(&doc);
298        assert!(text.contains("First"));
299        assert!(text.contains("Second"));
300    }
301
302    #[test]
303    fn text_to_adf_preserves_newlines() {
304        let original = "Line one\nLine two\nLine three";
305        let adf = text_to_adf(original);
306        let extracted = extract_adf_text(&adf);
307        assert!(extracted.contains("Line one"));
308        assert!(extracted.contains("Line two"));
309        assert!(extracted.contains("Line three"));
310    }
311
312    #[test]
313    fn text_to_adf_single_line_roundtrip() {
314        let original = "My description text";
315        let adf = text_to_adf(original);
316        let extracted = extract_adf_text(&adf);
317        assert_eq!(extracted, original);
318    }
319
320    #[test]
321    fn text_to_adf_blank_line_produces_empty_paragraph() {
322        let adf = text_to_adf("First\n\nThird");
323        let content = adf["content"].as_array().unwrap();
324        assert_eq!(content.len(), 3);
325        // The blank middle line must produce an empty content array, not a text node
326        // with an empty string — the latter is rejected by some Jira Cloud instances.
327        let blank_paragraph = &content[1];
328        assert_eq!(blank_paragraph["type"], "paragraph");
329        let blank_content = blank_paragraph["content"].as_array().unwrap();
330        assert!(blank_content.is_empty());
331    }
332
333    #[test]
334    fn escape_jql_double_quotes() {
335        assert_eq!(escape_jql(r#"say "hello""#), r#"say \"hello\""#);
336    }
337
338    #[test]
339    fn escape_jql_clean_input() {
340        assert_eq!(escape_jql("In Progress"), "In Progress");
341    }
342
343    #[test]
344    fn escape_jql_backslash() {
345        assert_eq!(escape_jql(r"foo\bar"), r"foo\\bar");
346    }
347}