Skip to main content

jira_cli/commands/
issues.rs

1mod bulk;
2mod create_meta;
3pub use create_meta::create_meta;
4mod transitions;
5pub use bulk::{bulk_assign, bulk_transition};
6
7use std::path::{Path, PathBuf};
8
9use owo_colors::OwoColorize;
10
11use crate::api::{
12    ApiError, Attachment, Issue, IssueDraft, IssueLink, IssueUpdate, JiraClient, UserField,
13    Version, escape_jql,
14};
15use crate::output::{OutputConfig, use_color};
16
17/// Filter set shared by `issues list` and `issues mine`.
18///
19/// Each `Option<&str>` field maps to one CLI flag and emits one JQL clause when set.
20/// `components`, `labels`, and `fix_versions` are slices because the CLI accepts those flags repeatably.
21#[derive(Default)]
22pub struct ListFilters<'a> {
23    pub project: Option<&'a str>,
24    pub status: Option<&'a str>,
25    pub assignee: Option<&'a str>,
26    pub issue_type: Option<&'a str>,
27    pub sprint: Option<&'a str>,
28    pub components: Option<&'a [&'a str]>,
29    pub labels: Option<&'a [&'a str]>,
30    pub fix_versions: Option<&'a [&'a str]>,
31    pub jql_extra: Option<&'a str>,
32}
33
34pub async fn list(
35    client: &JiraClient,
36    out: &OutputConfig,
37    filters: ListFilters<'_>,
38    limit: usize,
39    offset: usize,
40    all: bool,
41    fields: Option<&[String]>,
42) -> Result<(), ApiError> {
43    let jql = build_list_jql(&filters);
44    enable_epic_lookup_for(client, out, fields);
45    if all {
46        let issues = fetch_all_issues(client, &jql).await?;
47        let n = issues.len();
48        render_results(
49            out,
50            &issues,
51            PageInfo {
52                total: Some(n),
53                start_at: 0,
54                max_results: n,
55                more: false,
56            },
57            client,
58            fields,
59        );
60    } else {
61        let resp = client.search(&jql, limit, offset).await?;
62        let more = !resp.is_last;
63        render_results(
64            out,
65            &resp.issues,
66            PageInfo {
67                total: resp.total,
68                start_at: resp.start_at,
69                max_results: resp.max_results,
70                more,
71            },
72            client,
73            fields,
74        );
75    }
76    Ok(())
77}
78
79/// List issues assigned to the current user.
80pub async fn mine(
81    client: &JiraClient,
82    out: &OutputConfig,
83    mut filters: ListFilters<'_>,
84    limit: usize,
85    all: bool,
86    fields: Option<&[String]>,
87) -> Result<(), ApiError> {
88    filters.assignee = Some("me");
89    list(client, out, filters, limit, 0, all, fields).await
90}
91
92/// List comments on an issue.
93pub async fn comments(client: &JiraClient, out: &OutputConfig, key: &str) -> Result<(), ApiError> {
94    let issue = client.get_issue(key).await?;
95    let comment_list = issue.fields.comment.as_ref();
96
97    if out.json {
98        let comments_json: Vec<serde_json::Value> = comment_list
99            .map(|cl| {
100                cl.comments
101                    .iter()
102                    .map(|c| {
103                        serde_json::json!({
104                            "id": c.id,
105                            "author": user_to_json(Some(&c.author)),
106                            "body": c.body_text(),
107                            "created": c.created,
108                            "updated": c.updated,
109                        })
110                    })
111                    .collect()
112            })
113            .unwrap_or_default();
114        let total = comment_list.map(|cl| cl.total).unwrap_or(0);
115        out.print_data(
116            &serde_json::to_string_pretty(&serde_json::json!({
117                "issue": key,
118                "total": total,
119                "comments": comments_json,
120            }))
121            .expect("failed to serialize JSON"),
122        );
123    } else {
124        match comment_list {
125            None => {
126                out.print_message(&format!("No comments on {key}."));
127            }
128            Some(cl) if cl.comments.is_empty() => {
129                out.print_message(&format!("No comments on {key}."));
130            }
131            Some(cl) => {
132                let color = use_color();
133                out.print_message(&format!("Comments on {key} ({}):", cl.total));
134                for c in &cl.comments {
135                    println!();
136                    let author = if color {
137                        c.author.display_name.bold().to_string()
138                    } else {
139                        c.author.display_name.clone()
140                    };
141                    println!("  {} - {}", author, format_date(&c.created));
142                    for line in c.body_text().lines() {
143                        println!("    {line}");
144                    }
145                }
146            }
147        }
148    }
149    Ok(())
150}
151
152/// Fetch every page of a JQL search, returning all issues.
153pub async fn fetch_all_issues(client: &JiraClient, jql: &str) -> Result<Vec<Issue>, ApiError> {
154    const PAGE_SIZE: usize = 100;
155    let mut all: Vec<Issue> = Vec::new();
156    let mut offset = 0;
157    loop {
158        let resp = client.search(jql, PAGE_SIZE, offset).await?;
159        let fetched = resp.issues.len();
160        all.extend(resp.issues);
161        offset += fetched;
162        if resp.is_last || fetched == 0 {
163            break;
164        }
165    }
166    Ok(all)
167}
168
169struct PageInfo {
170    total: Option<usize>,
171    start_at: usize,
172    max_results: usize,
173    more: bool,
174}
175
176fn render_results(
177    out: &OutputConfig,
178    issues: &[Issue],
179    page: PageInfo,
180    client: &JiraClient,
181    fields: Option<&[String]>,
182) {
183    if out.json {
184        let total_json: serde_json::Value = match page.total {
185            Some(n) => serde_json::json!(n),
186            None => serde_json::Value::Null,
187        };
188        let items: Vec<serde_json::Value> = issues
189            .iter()
190            .map(|i| filter_fields(issue_to_json(i, client), fields))
191            .collect();
192        out.print_data(
193            &serde_json::to_string_pretty(&serde_json::json!({
194                "items": items,
195                "total": total_json,
196                "startAt": page.start_at,
197                "maxResults": page.max_results,
198            }))
199            .expect("failed to serialize JSON"),
200        );
201    } else {
202        render_issue_table(issues, out);
203        if page.more {
204            match page.total {
205                Some(n) => out.print_message(&format!(
206                    "Showing {}-{} of {} issues - use --limit/--offset or --all to paginate",
207                    page.start_at + 1,
208                    page.start_at + issues.len(),
209                    n
210                )),
211                None => out.print_message(&format!(
212                    "Showing {}-{} issues (more available) - use --limit/--offset or --all to paginate",
213                    page.start_at + 1,
214                    page.start_at + issues.len()
215                )),
216            }
217        } else {
218            out.print_message(&format!("{} issues", issues.len()));
219        }
220    }
221}
222
223/// Look epics up only when the output includes them: listing tables never do.
224pub(crate) fn enable_epic_lookup_for(
225    client: &JiraClient,
226    out: &OutputConfig,
227    fields: Option<&[String]>,
228) {
229    if out.json && fields.is_none_or(|names| names.iter().any(|n| n == "epic")) {
230        client.enable_epic_lookup();
231    }
232}
233
234/// Parse a comma-separated `--fields` argument, refusing names that are not
235/// keys of the issue JSON. An unknown name must fail rather than quietly
236/// filter every item down to an object that looks like the field is empty.
237pub fn parse_fields_arg(arg: &str) -> Result<Vec<String>, ApiError> {
238    let names: Vec<String> = arg
239        .split(',')
240        .map(|f| f.trim().to_owned())
241        .filter(|f| !f.is_empty())
242        .collect();
243    if names.is_empty() {
244        return Err(ApiError::InvalidInput(format!(
245            "--fields needs at least one name. Valid names: {}",
246            ISSUE_SUMMARY_KEYS.join(", ")
247        )));
248    }
249    let unknown: Vec<&str> = names
250        .iter()
251        .map(String::as_str)
252        .filter(|name| !ISSUE_SUMMARY_KEYS.contains(name))
253        .collect();
254    if !unknown.is_empty() {
255        return Err(ApiError::InvalidInput(format!(
256            "Unknown --fields name(s): {}. Valid names: {}",
257            unknown.join(", "),
258            ISSUE_SUMMARY_KEYS.join(", ")
259        )));
260    }
261    Ok(names)
262}
263
264/// Retain only the requested fields from a JSON object. When `fields` is `None`
265/// or empty, the original value is returned unchanged.
266pub fn filter_fields(mut value: serde_json::Value, fields: Option<&[String]>) -> serde_json::Value {
267    let Some(names) = fields else { return value };
268    if names.is_empty() {
269        return value;
270    }
271    if let Some(obj) = value.as_object_mut() {
272        obj.retain(|k, _| names.iter().any(|f| f == k));
273    }
274    value
275}
276
277pub async fn show(
278    client: &JiraClient,
279    out: &OutputConfig,
280    key: &str,
281    open: bool,
282) -> Result<(), ApiError> {
283    client.enable_epic_lookup();
284    let issue = client.get_issue(key).await?;
285
286    if open {
287        open_in_browser(&client.browse_url(&issue.key));
288    }
289
290    if out.json {
291        out.print_data(
292            &serde_json::to_string_pretty(&issue_detail_to_json(&issue, client))
293                .expect("failed to serialize JSON"),
294        );
295    } else {
296        render_issue_detail(&issue);
297    }
298    Ok(())
299}
300
301pub async fn create(
302    client: &JiraClient,
303    out: &OutputConfig,
304    draft: &IssueDraft<'_>,
305    sprint: Option<&str>,
306    board: Option<u64>,
307    custom_fields: &[(String, serde_json::Value)],
308    dry_run: bool,
309) -> Result<(), ApiError> {
310    let project = custom_fields
311        .iter()
312        .rev()
313        .find(|(key, _)| key == "project")
314        .and_then(|(_, value)| value["key"].as_str().or_else(|| value["id"].as_str()))
315        .unwrap_or(draft.project_key);
316    let resolved_sprint = match sprint {
317        Some(s) => {
318            let resolved = client
319                .resolve_sprint_scoped(s, Some(project), board)
320                .await?;
321            validate_writable_sprint(&resolved)?;
322            Some(resolved)
323        }
324        None if board.is_some() => {
325            return Err(ApiError::InvalidInput("--board requires --sprint".into()));
326        }
327        None => None,
328    };
329    if dry_run {
330        let prepared = client.preview_create_issue(draft, custom_fields).await?;
331        print_write_preview(out, "create", None, prepared, resolved_sprint.as_ref());
332        return Ok(());
333    }
334    let resp = client.create_issue(draft, custom_fields).await?;
335    let url = client.browse_url(&resp.key);
336
337    let mut result = serde_json::json!({ "key": resp.key, "id": resp.id, "url": url });
338    if let Some(p) = draft.parent {
339        result["parent"] = serde_json::json!(p);
340    }
341    if let Some(epic) = draft.epic {
342        result["epic"] = serde_json::json!(epic);
343    }
344    if let Some(resolved) = resolved_sprint {
345        client
346            .move_issue_to_sprint(&resp.key, resolved.id)
347            .await
348            .map_err(|source| ApiError::PartialSuccess {
349                key: resp.key.clone(),
350                url: url.clone(),
351                sprint_id: resolved.id,
352                source: Box::new(source),
353            })?;
354        result["sprintId"] = serde_json::json!(resolved.id);
355        result["sprintName"] = serde_json::json!(resolved.name);
356    }
357    out.print_result(&result, &resp.key);
358    Ok(())
359}
360
361pub async fn update(
362    client: &JiraClient,
363    out: &OutputConfig,
364    key: &str,
365    update: &IssueUpdate<'_>,
366    custom_fields: &[(String, serde_json::Value)],
367    dry_run: bool,
368) -> Result<(), ApiError> {
369    if dry_run {
370        let prepared = client
371            .preview_update_issue(key, update, custom_fields)
372            .await?;
373        print_write_preview(out, "update", Some(key), prepared, None);
374        return Ok(());
375    }
376    client.update_issue(key, update, custom_fields).await?;
377    out.print_result(
378        &serde_json::json!({ "key": key, "updated": true }),
379        &format!("Updated {key}"),
380    );
381    Ok(())
382}
383
384/// Move an issue to a sprint.
385pub async fn move_to_sprint(
386    client: &JiraClient,
387    out: &OutputConfig,
388    key: &str,
389    sprint: &str,
390    board: Option<u64>,
391    dry_run: bool,
392) -> Result<(), ApiError> {
393    // Also confirms that a preview targets an existing issue when the sprint
394    // is supplied by ID or explicitly scoped to a board.
395    let project = if board.is_none() && sprint.trim().parse::<u64>().is_err() {
396        Some(client.issue_project(key).await?)
397    } else {
398        None
399    };
400    let resolved = client
401        .resolve_sprint_scoped(sprint, project.as_deref(), board)
402        .await?;
403    validate_writable_sprint(&resolved)?;
404    if dry_run {
405        if project.is_none() {
406            client.issue_project(key).await?;
407        }
408        let prepared = serde_json::json!({"fields":null, "metadata":{"issueTypes":null,"fields":null},
409            "warnings":["Jira may apply additional permission or workflow validators when the move is submitted."]});
410        print_write_preview(out, "move", Some(key), prepared, Some(&resolved));
411        return Ok(());
412    }
413    client.move_issue_to_sprint(key, resolved.id).await?;
414    out.print_result(
415        &serde_json::json!({
416            "issue": key,
417            "sprintId": resolved.id,
418            "sprintName": resolved.name,
419        }),
420        &format!("Moved {key} to {} ({})", resolved.name, resolved.id),
421    );
422    Ok(())
423}
424
425fn print_write_preview(
426    out: &OutputConfig,
427    operation: &str,
428    key: Option<&str>,
429    mut prepared: serde_json::Value,
430    sprint: Option<&crate::api::Sprint>,
431) {
432    prepared["dryRun"] = serde_json::json!(true);
433    prepared["operation"] = serde_json::json!(operation);
434    prepared["key"] = serde_json::json!(key);
435    prepared["sprint"] = serde_json::json!(
436        sprint.map(|s| serde_json::json!({"id":s.id,"name":s.name,"state":s.state}))
437    );
438    let mut steps = match operation {
439        "create" => vec!["create_issue"],
440        "update" => vec!["update_issue"],
441        _ => vec![],
442    };
443    if sprint.is_some() {
444        steps.push("move_to_sprint");
445    }
446    prepared["steps"] = serde_json::json!(steps);
447    // Text previews retain the exact normalized fields, too.
448    out.print_result(
449        &prepared,
450        &format!(
451            "Dry run: no changes made.\n{}",
452            serde_json::to_string_pretty(&prepared).expect("preview JSON")
453        ),
454    );
455}
456
457fn validate_writable_sprint(sprint: &crate::api::Sprint) -> Result<(), ApiError> {
458    if sprint.state != "active" && sprint.state != "future" {
459        return Err(ApiError::InvalidInput(format!(
460            "Sprint {} ({:?}) is {}; choose an active or future sprint",
461            sprint.id, sprint.name, sprint.state
462        )));
463    }
464    Ok(())
465}
466
467pub async fn comment(
468    client: &JiraClient,
469    out: &OutputConfig,
470    key: &str,
471    body: &str,
472) -> Result<(), ApiError> {
473    let c = client.add_comment(key, body).await?;
474    let url = client.browse_url(key);
475    out.print_result(
476        &serde_json::json!({
477            "id": c.id,
478            "issue": key,
479            "url": url,
480            "author": c.author.display_name,
481            "created": c.created,
482        }),
483        &format!("Comment added to {key}"),
484    );
485    Ok(())
486}
487
488pub async fn transition(
489    client: &JiraClient,
490    out: &OutputConfig,
491    key: &str,
492    to: &str,
493) -> Result<(), ApiError> {
494    let available = client.get_transitions(key).await?;
495    let selected = transitions::resolve(key, to, &available)?;
496    client.do_transition(key, &selected.id).await?;
497    let status = selected
498        .to
499        .as_ref()
500        .map_or(selected.name.as_str(), |s| s.name.as_str());
501    out.print_result(
502        &serde_json::json!({"issue": key, "transition": selected.name, "status": status, "id": selected.id}),
503        &format!("Transitioned {key} to {status}"),
504    );
505    Ok(())
506}
507
508pub async fn list_transitions(
509    client: &JiraClient,
510    out: &OutputConfig,
511    key: &str,
512) -> Result<(), ApiError> {
513    let ts = client.get_transitions(key).await?;
514
515    if out.json {
516        out.print_data(&serde_json::to_string_pretty(&ts).expect("failed to serialize JSON"));
517    } else {
518        let color = use_color();
519        let header = format!("{:<6} {}", "ID", "Name");
520        if color {
521            println!("{}", header.bold());
522        } else {
523            println!("{header}");
524        }
525        for t in &ts {
526            println!("{:<6} {}", t.id, t.name);
527        }
528    }
529    Ok(())
530}
531
532pub async fn assign(
533    client: &JiraClient,
534    out: &OutputConfig,
535    key: &str,
536    assignee: &str,
537) -> Result<(), ApiError> {
538    let account_id = if assignee == "me" {
539        let me = client.get_myself().await?;
540        me.account_id
541    } else if assignee == "none" || assignee == "unassign" {
542        client.assign_issue(key, None).await?;
543        out.print_result(
544            // Both paths emit the same keys. A null `accountId` is the unassigned
545            // state; a separate `assignee` key here would make the shape of the
546            // response depend on which path ran.
547            &serde_json::json!({ "issue": key, "accountId": null }),
548            &format!("Unassigned {key}"),
549        );
550        return Ok(());
551    } else {
552        assignee.to_string()
553    };
554
555    client.assign_issue(key, Some(&account_id)).await?;
556    out.print_result(
557        &serde_json::json!({ "issue": key, "accountId": account_id }),
558        &format!("Assigned {key} to {assignee}"),
559    );
560    Ok(())
561}
562
563/// List available issue link types.
564pub async fn link_types(client: &JiraClient, out: &OutputConfig) -> Result<(), ApiError> {
565    let types = client.get_link_types().await?;
566
567    if out.json {
568        out.print_data(
569            &serde_json::to_string_pretty(&serde_json::json!(
570                types
571                    .iter()
572                    .map(|t| serde_json::json!({
573                        "id": t.id,
574                        "name": t.name,
575                        "inward": t.inward,
576                        "outward": t.outward,
577                    }))
578                    .collect::<Vec<_>>()
579            ))
580            .expect("failed to serialize JSON"),
581        );
582        return Ok(());
583    }
584
585    for t in &types {
586        println!(
587            "{:<20}  outward: {}  /  inward: {}",
588            t.name, t.outward, t.inward
589        );
590    }
591    Ok(())
592}
593
594/// Link two issues.
595pub async fn link(
596    client: &JiraClient,
597    out: &OutputConfig,
598    from_key: &str,
599    to_key: &str,
600    link_type: &str,
601) -> Result<(), ApiError> {
602    client.link_issues(from_key, to_key, link_type).await?;
603    out.print_result(
604        &serde_json::json!({
605            "from": from_key,
606            "to": to_key,
607            "type": link_type,
608        }),
609        &format!("Linked {from_key} → {to_key} ({link_type})"),
610    );
611    Ok(())
612}
613
614/// Remove an issue link by link ID.
615pub async fn unlink(
616    client: &JiraClient,
617    out: &OutputConfig,
618    link_id: &str,
619) -> Result<(), ApiError> {
620    client.unlink_issues(link_id).await?;
621    out.print_result(
622        &serde_json::json!({ "linkId": link_id }),
623        &format!("Removed link {link_id}"),
624    );
625    Ok(())
626}
627
628/// Log work (time) on an issue.
629pub async fn log_work(
630    client: &JiraClient,
631    out: &OutputConfig,
632    key: &str,
633    time_spent: &str,
634    comment: Option<&str>,
635    started: Option<&str>,
636) -> Result<(), ApiError> {
637    let entry = client.log_work(key, time_spent, comment, started).await?;
638    out.print_result(
639        &serde_json::json!({
640            "id": entry.id,
641            "issue": key,
642            "timeSpent": entry.time_spent,
643            "timeSpentSeconds": entry.time_spent_seconds,
644            "author": entry.author.display_name,
645            "started": entry.started,
646            "created": entry.created,
647        }),
648        &format!("Logged {} on {key}", entry.time_spent),
649    );
650    Ok(())
651}
652
653/// List the attachments on an issue.
654pub async fn attachments(
655    client: &JiraClient,
656    out: &OutputConfig,
657    key: &str,
658) -> Result<(), ApiError> {
659    let attachments = client.list_attachments(key).await?;
660
661    if out.json {
662        out.print_data(
663            &serde_json::to_string_pretty(&serde_json::json!({
664                "issue": key,
665                "total": attachments.len(),
666                "attachments": attachments.iter().map(attachment_to_json).collect::<Vec<_>>(),
667            }))
668            .expect("failed to serialize JSON"),
669        );
670        return Ok(());
671    }
672
673    if attachments.is_empty() {
674        out.print_message(&format!("No attachments on {key}."));
675        return Ok(());
676    }
677
678    let color = use_color();
679    let sizes: Vec<String> = attachments.iter().map(|a| format_size(a.size)).collect();
680    let id_w = col_width("ID", attachments.iter().map(|a| a.id.len()));
681    let name_w = col_width("Filename", attachments.iter().map(|a| a.filename.len()));
682    let size_w = col_width("Size", sizes.iter().map(String::len));
683    let type_w = col_width("Type", attachments.iter().map(|a| a.mime_type().len()));
684    let author_w = col_width("Author", attachments.iter().map(|a| a.author().len()));
685
686    let header = format!(
687        "{:<id_w$} {:<name_w$} {:<size_w$} {:<type_w$} {:<author_w$} {}",
688        "ID", "Filename", "Size", "Type", "Author", "Created"
689    );
690    if color {
691        println!("{}", header.bold());
692    } else {
693        println!("{header}");
694    }
695
696    for (a, size) in attachments.iter().zip(&sizes) {
697        let id = if color {
698            format!("{:<id_w$}", a.id).yellow().to_string()
699        } else {
700            format!("{:<id_w$}", a.id)
701        };
702        println!(
703            "{id} {:<name_w$} {:<size_w$} {:<type_w$} {:<author_w$} {}",
704            a.filename,
705            size,
706            a.mime_type(),
707            a.author(),
708            format_date(&a.created),
709        );
710    }
711    out.print_message(&format!("{} attachments", attachments.len()));
712    Ok(())
713}
714
715/// Upload one or more local files to an issue.
716pub async fn attach(
717    client: &JiraClient,
718    out: &OutputConfig,
719    key: &str,
720    files: &[PathBuf],
721) -> Result<(), ApiError> {
722    let uploaded = client.upload_attachments(key, files).await?;
723    let names: Vec<&str> = uploaded.iter().map(|a| a.filename.as_str()).collect();
724    out.print_result(
725        &serde_json::json!({
726            "issue": key,
727            "attachments": uploaded.iter().map(attachment_to_json).collect::<Vec<_>>(),
728        }),
729        &format!("Attached {} to {key}", names.join(", ")),
730    );
731    Ok(())
732}
733
734/// Download an attachment into `dir`, using the filename Jira reports for it.
735///
736/// The directory is created and the target checked for an existing file before
737/// any content is fetched, so a refusal never happens after the download.
738pub async fn download_attachment(
739    client: &JiraClient,
740    out: &OutputConfig,
741    id: &str,
742    dir: &Path,
743    force: bool,
744) -> Result<(), ApiError> {
745    let attachment = client.get_attachment(id).await?;
746    let file_name = safe_file_name(&attachment.filename).ok_or_else(|| {
747        ApiError::Other(format!(
748            "Attachment {id} has an unusable filename '{}'",
749            attachment.filename
750        ))
751    })?;
752
753    std::fs::create_dir_all(dir)
754        .map_err(|e| ApiError::Other(format!("cannot create {}: {e}", dir.display())))?;
755
756    let path = dir.join(file_name);
757    if !force && path.exists() {
758        return Err(ApiError::Conflict(format!(
759            "{} already exists. Pass --force to overwrite.",
760            path.display()
761        )));
762    }
763
764    let data = client.download_attachment(id).await?;
765    std::fs::write(&path, &data)
766        .map_err(|e| ApiError::Other(format!("cannot write {}: {e}", path.display())))?;
767
768    out.print_result(
769        &serde_json::json!({
770            "id": attachment.id,
771            "filename": file_name,
772            "path": path.display().to_string(),
773            "size": data.len(),
774        }),
775        &format!("Downloaded {file_name} to {}", path.display()),
776    );
777    Ok(())
778}
779
780/// Delete an attachment by its ID.
781pub async fn delete_attachment(
782    client: &JiraClient,
783    out: &OutputConfig,
784    id: &str,
785) -> Result<(), ApiError> {
786    client.delete_attachment(id).await?;
787    out.print_result(
788        &serde_json::json!({ "id": id, "deleted": true }),
789        &format!("Deleted attachment {id}"),
790    );
791    Ok(())
792}
793
794// ── Rendering ─────────────────────────────────────────────────────────────────
795
796pub(crate) fn render_issue_table(issues: &[Issue], out: &OutputConfig) {
797    if issues.is_empty() {
798        out.print_message("No issues found.");
799        return;
800    }
801
802    let color = use_color();
803    let term_width = terminal_width();
804
805    let key_w = issues.iter().map(|i| i.key.len()).max().unwrap_or(4).max(4) + 1;
806    let status_w = issues
807        .iter()
808        .map(|i| i.status().len())
809        .max()
810        .unwrap_or(6)
811        .clamp(6, 14)
812        + 2;
813    let assignee_w = issues
814        .iter()
815        .map(|i| i.assignee().len())
816        .max()
817        .unwrap_or(8)
818        .clamp(8, 18)
819        + 2;
820    let type_w = issues
821        .iter()
822        .map(|i| i.issue_type().len())
823        .max()
824        .unwrap_or(4)
825        .clamp(4, 12)
826        + 2;
827
828    // Give remaining width to summary, minimum 20
829    let fixed = key_w + 1 + status_w + 1 + assignee_w + 1 + type_w + 1;
830    let summary_w = term_width.saturating_sub(fixed).max(20);
831
832    let header = format!(
833        "{:<key_w$} {:<status_w$} {:<assignee_w$} {:<type_w$} {}",
834        "Key", "Status", "Assignee", "Type", "Summary"
835    );
836    if color {
837        println!("{}", header.bold());
838    } else {
839        println!("{header}");
840    }
841
842    for issue in issues {
843        let key = if color {
844            format!("{:<key_w$}", issue.key).yellow().to_string()
845        } else {
846            format!("{:<key_w$}", issue.key)
847        };
848        let status_val = truncate(issue.status(), status_w - 2);
849        let status = if color {
850            colorize_status(issue.status(), &format!("{:<status_w$}", status_val))
851        } else {
852            format!("{:<status_w$}", status_val)
853        };
854        println!(
855            "{key} {status} {:<assignee_w$} {:<type_w$} {}",
856            truncate(issue.assignee(), assignee_w - 2),
857            truncate(issue.issue_type(), type_w - 2),
858            truncate(issue.summary(), summary_w),
859        );
860    }
861}
862
863fn render_issue_detail(issue: &Issue) {
864    let mut stdout = std::io::stdout().lock();
865    write_issue_detail(&mut stdout, issue).expect("stdout write");
866}
867
868fn write_issue_detail<W: std::io::Write>(out: &mut W, issue: &Issue) -> std::io::Result<()> {
869    let color = use_color();
870    let key = if color {
871        issue.key.yellow().bold().to_string()
872    } else {
873        issue.key.clone()
874    };
875    writeln!(out, "{key}  {}", issue.summary())?;
876    writeln!(out)?;
877    writeln!(out, "  Type:       {}", issue.issue_type())?;
878    let status_str = if color {
879        colorize_status(issue.status(), issue.status())
880    } else {
881        issue.status().to_string()
882    };
883    writeln!(out, "  Status:     {status_str}")?;
884    writeln!(out, "  Priority:   {}", issue.priority())?;
885    writeln!(out, "  Assignee:   {}", issue.assignee())?;
886    if let Some(ref parent) = issue.fields.parent {
887        let parent_type = parent
888            .fields
889            .as_ref()
890            .and_then(|f| f.issuetype.as_ref())
891            .map(|t| format!(" ({})", t.name))
892            .unwrap_or_default();
893        writeln!(out, "  Parent:     {}{parent_type}", parent.key)?;
894    }
895    if let Some(ref epic) = issue.epic {
896        writeln!(out, "  Epic:       {epic}")?;
897    }
898    if let Some(ref reporter) = issue.fields.reporter {
899        writeln!(out, "  Reporter:   {}", reporter.display_name)?;
900    }
901    if let Some(ref labels) = issue.fields.labels
902        && !labels.is_empty()
903    {
904        writeln!(out, "  Labels:     {}", labels.join(", "))?;
905    }
906    if let Some(ref components) = issue.fields.components
907        && !components.is_empty()
908    {
909        let names: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
910        writeln!(out, "  Components: {}", names.join(", "))?;
911    }
912    if let Some(ref fix_versions) = issue.fields.fix_versions
913        && !fix_versions.is_empty()
914    {
915        let names: Vec<&str> = fix_versions.iter().map(|v| v.name.as_str()).collect();
916        writeln!(out, "  Fix Versions:     {}", names.join(", "))?;
917    }
918    if let Some(ref versions) = issue.fields.versions
919        && !versions.is_empty()
920    {
921        let names: Vec<&str> = versions.iter().map(|v| v.name.as_str()).collect();
922        writeln!(out, "  Affects Versions: {}", names.join(", "))?;
923    }
924    if let Some(ref created) = issue.fields.created {
925        writeln!(out, "  Created:    {}", format_date(created))?;
926    }
927    if let Some(ref updated) = issue.fields.updated {
928        writeln!(out, "  Updated:    {}", format_date(updated))?;
929    }
930
931    let desc = issue.description_text();
932    if !desc.is_empty() {
933        writeln!(out)?;
934        writeln!(out, "Description:")?;
935        for line in desc.lines() {
936            writeln!(out, "  {line}")?;
937        }
938    }
939
940    if let Some(ref links) = issue.fields.issue_links
941        && !links.is_empty()
942    {
943        writeln!(out)?;
944        writeln!(out, "Links:")?;
945        for link in links {
946            write_issue_link(out, link)?;
947        }
948    }
949
950    if let Some(ref comment_list) = issue.fields.comment
951        && !comment_list.comments.is_empty()
952    {
953        writeln!(out)?;
954        writeln!(out, "Comments ({}):", comment_list.total)?;
955        for c in &comment_list.comments {
956            writeln!(out)?;
957            let author = if color {
958                c.author.display_name.bold().to_string()
959            } else {
960                c.author.display_name.clone()
961            };
962            writeln!(out, "  {} - {}", author, format_date(&c.created))?;
963            let body = c.body_text();
964            for line in body.lines() {
965                writeln!(out, "    {line}")?;
966            }
967        }
968    }
969    Ok(())
970}
971
972fn write_issue_link<W: std::io::Write>(out: &mut W, link: &IssueLink) -> std::io::Result<()> {
973    if let Some(ref out_issue) = link.outward_issue {
974        writeln!(
975            out,
976            "  [{}] {} {} - {}",
977            link.id, link.link_type.outward, out_issue.key, out_issue.fields.summary
978        )?;
979    }
980    if let Some(ref in_issue) = link.inward_issue {
981        writeln!(
982            out,
983            "  [{}] {} {} - {}",
984            link.id, link.link_type.inward, in_issue.key, in_issue.fields.summary
985        )?;
986    }
987    Ok(())
988}
989
990// ── JSON serialization ────────────────────────────────────────────────────────
991
992/// Render a user as JSON, or `null` when Jira reported none.
993///
994/// The table accessors collapse an absent user to `"-"`, which is the right
995/// placeholder for a column but a lie in JSON: it makes "nobody is assigned"
996/// indistinguishable from a user whose display name is literally `-`. JSON
997/// carries the absence itself.
998fn user_to_json(user: Option<&UserField>) -> serde_json::Value {
999    match user {
1000        Some(u) => serde_json::json!({
1001            "displayName": u.display_name,
1002            "accountId": u.account_id,
1003        }),
1004        None => serde_json::Value::Null,
1005    }
1006}
1007
1008/// Keys of an `issue_to_json` object: the names `--fields` accepts.
1009pub(crate) const ISSUE_SUMMARY_KEYS: [&str; 12] = [
1010    "key", "id", "url", "summary", "status", "assignee", "priority", "type", "parent", "epic",
1011    "created", "updated",
1012];
1013
1014fn parent_to_json(issue: &Issue) -> serde_json::Value {
1015    match &issue.fields.parent {
1016        Some(parent) => {
1017            let fields = parent.fields.as_ref();
1018            serde_json::json!({
1019                "key": parent.key,
1020                "summary": fields.and_then(|f| f.summary.as_deref()),
1021                "type": fields.and_then(|f| f.issuetype.as_ref()).map(|t| t.name.as_str()),
1022            })
1023        }
1024        None => serde_json::Value::Null,
1025    }
1026}
1027
1028pub(crate) fn issue_to_json(issue: &Issue, client: &JiraClient) -> serde_json::Value {
1029    serde_json::json!({
1030        "key": issue.key,
1031        "id": issue.id,
1032        "url": client.browse_url(&issue.key),
1033        "summary": issue.summary(),
1034        "status": issue.status(),
1035        "assignee": user_to_json(issue.fields.assignee.as_ref()),
1036        // Projects can leave priority unset, so the field is nullable for the
1037        // same reason `assignee` is.
1038        "priority": issue.fields.priority.as_ref().map(|p| p.name.as_str()),
1039        "type": issue.issue_type(),
1040        "parent": parent_to_json(issue),
1041        "epic": issue.epic,
1042        "created": issue.fields.created,
1043        "updated": issue.fields.updated,
1044    })
1045}
1046
1047fn attachment_to_json(a: &Attachment) -> serde_json::Value {
1048    serde_json::json!({
1049        "id": a.id,
1050        "filename": a.filename,
1051        "size": a.size,
1052        "mimeType": a.mime_type,
1053        // `author()` renders "-" for the table. JSON carries the absence itself,
1054        // so a consumer can tell an unattributed upload from one by a user
1055        // literally named "-".
1056        "author": a.author.as_ref().map(|u| u.display_name.as_str()),
1057        "created": a.created,
1058    })
1059}
1060
1061fn version_to_json(v: &Version) -> serde_json::Value {
1062    serde_json::json!({
1063        "id": v.id,
1064        "name": v.name,
1065        "description": v.description,
1066        "released": v.released,
1067        "archived": v.archived,
1068        "releaseDate": v.release_date,
1069    })
1070}
1071
1072pub fn issue_detail_to_json(issue: &Issue, client: &JiraClient) -> serde_json::Value {
1073    let comments: Vec<serde_json::Value> = issue
1074        .fields
1075        .comment
1076        .as_ref()
1077        .map(|cl| {
1078            cl.comments
1079                .iter()
1080                .map(|c| {
1081                    serde_json::json!({
1082                        "id": c.id,
1083                        "author": user_to_json(Some(&c.author)),
1084                        "body": c.body_text(),
1085                        "created": c.created,
1086                        "updated": c.updated,
1087                    })
1088                })
1089                .collect()
1090        })
1091        .unwrap_or_default();
1092
1093    let issue_links: Vec<serde_json::Value> = issue
1094        .fields
1095        .issue_links
1096        .as_deref()
1097        .unwrap_or_default()
1098        .iter()
1099        .map(|link| {
1100            let sentence = if let Some(ref out_issue) = link.outward_issue {
1101                format!("{} {} {}", issue.key, link.link_type.outward, out_issue.key)
1102            } else if let Some(ref in_issue) = link.inward_issue {
1103                format!("{} {} {}", issue.key, link.link_type.inward, in_issue.key)
1104            } else {
1105                String::new()
1106            };
1107            serde_json::json!({
1108                "id": link.id,
1109                "sentence": sentence,
1110                "type": {
1111                    "id": link.link_type.id,
1112                    "name": link.link_type.name,
1113                    "inward": link.link_type.inward,
1114                    "outward": link.link_type.outward,
1115                },
1116                "outwardIssue": link.outward_issue.as_ref().map(|i| serde_json::json!({
1117                    "key": i.key,
1118                    "summary": i.fields.summary,
1119                    "status": i.fields.status.name,
1120                })),
1121                "inwardIssue": link.inward_issue.as_ref().map(|i| serde_json::json!({
1122                    "key": i.key,
1123                    "summary": i.fields.summary,
1124                    "status": i.fields.status.name,
1125                })),
1126            })
1127        })
1128        .collect();
1129
1130    serde_json::json!({
1131        "key": issue.key,
1132        "id": issue.id,
1133        "url": client.browse_url(&issue.key),
1134        "summary": issue.summary(),
1135        "status": issue.status(),
1136        "type": issue.issue_type(),
1137        "priority": issue.fields.priority.as_ref().map(|p| p.name.as_str()),
1138        "assignee": user_to_json(issue.fields.assignee.as_ref()),
1139        "reporter": user_to_json(issue.fields.reporter.as_ref()),
1140        "labels": issue.fields.labels,
1141        "components": issue.fields.components,
1142        "fixVersions": issue.fields.fix_versions.as_ref().map(|fvs| {
1143            fvs.iter().map(version_to_json).collect::<Vec<_>>()
1144        }),
1145        "affectedVersions": issue.fields.versions.as_ref().map(|vs| {
1146            vs.iter().map(version_to_json).collect::<Vec<_>>()
1147        }),
1148        "parent": parent_to_json(issue),
1149        "epic": issue.epic,
1150        // Null when the issue has no description at all, so that state stays
1151        // distinguishable from a description that is present but empty.
1152        "description": issue.fields.description.as_ref().map(|_| issue.description_text()),
1153        "created": issue.fields.created,
1154        "updated": issue.fields.updated,
1155        "comments": comments,
1156        "issueLinks": issue_links,
1157    })
1158}
1159
1160// ── Helpers ───────────────────────────────────────────────────────────────────
1161
1162fn jql_multi_value(field: &str, values: &[&str]) -> Option<String> {
1163    match values.len() {
1164        0 => None,
1165        1 => Some(format!(r#"{field} = "{}""#, escape_jql(values[0]))),
1166        _ => {
1167            let quoted: Vec<String> = values
1168                .iter()
1169                .map(|v| format!(r#""{}""#, escape_jql(v)))
1170                .collect();
1171            Some(format!("{field} in ({})", quoted.join(", ")))
1172        }
1173    }
1174}
1175
1176fn build_list_jql(filters: &ListFilters<'_>) -> String {
1177    let mut parts: Vec<String> = Vec::new();
1178
1179    if let Some(p) = filters.project {
1180        parts.push(format!(r#"project = "{}""#, escape_jql(p)));
1181    }
1182    if let Some(s) = filters.status {
1183        parts.push(format!(r#"status = "{}""#, escape_jql(s)));
1184    }
1185    if let Some(a) = filters.assignee {
1186        if a == "me" {
1187            parts.push("assignee = currentUser()".into());
1188        } else {
1189            parts.push(format!(r#"assignee = "{}""#, escape_jql(a)));
1190        }
1191    }
1192    if let Some(t) = filters.issue_type {
1193        parts.push(format!(r#"issuetype = "{}""#, escape_jql(t)));
1194    }
1195    if let Some(s) = filters.sprint {
1196        if s == "active" || s == "open" {
1197            parts.push("sprint in openSprints()".into());
1198        } else {
1199            parts.push(format!(r#"sprint = "{}""#, escape_jql(s)));
1200        }
1201    }
1202    if let Some(comps) = filters.components {
1203        parts.extend(jql_multi_value("component", comps));
1204    }
1205    if let Some(lbls) = filters.labels {
1206        parts.extend(jql_multi_value("labels", lbls));
1207    }
1208    if let Some(fvs) = filters.fix_versions {
1209        parts.extend(jql_multi_value("fixVersion", fvs));
1210    }
1211    if let Some(e) = filters.jql_extra {
1212        parts.push(format!("({e})"));
1213    }
1214
1215    if parts.is_empty() {
1216        "ORDER BY updated DESC".into()
1217    } else {
1218        format!("{} ORDER BY updated DESC", parts.join(" AND "))
1219    }
1220}
1221
1222/// Color-code a Jira status string for terminal output.
1223fn colorize_status(status: &str, display: &str) -> String {
1224    let lower = status.to_lowercase();
1225    if lower.contains("done") || lower.contains("closed") || lower.contains("resolved") {
1226        display.green().to_string()
1227    } else if lower.contains("progress") || lower.contains("review") || lower.contains("testing") {
1228        display.yellow().to_string()
1229    } else if lower.contains("blocked") || lower.contains("impediment") {
1230        display.red().to_string()
1231    } else {
1232        display.to_string()
1233    }
1234}
1235
1236/// Open a URL in the system default browser, printing a warning if it fails.
1237fn open_in_browser(url: &str) {
1238    #[cfg(target_os = "macos")]
1239    let result = std::process::Command::new("open").arg(url).status();
1240    #[cfg(target_os = "linux")]
1241    let result = std::process::Command::new("xdg-open").arg(url).status();
1242    #[cfg(target_os = "windows")]
1243    let result = std::process::Command::new("cmd")
1244        .args(["/c", "start", url])
1245        .status();
1246
1247    #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
1248    if let Err(e) = result {
1249        eprintln!("Warning: could not open browser: {e}");
1250    }
1251}
1252
1253/// Truncate a string to `max` characters (not bytes), appending `…` if cut.
1254fn truncate(s: &str, max: usize) -> String {
1255    let mut chars = s.chars();
1256    let mut result: String = chars.by_ref().take(max).collect();
1257    if chars.next().is_some() {
1258        result.push('…');
1259    }
1260    result
1261}
1262
1263/// Shorten an ISO-8601 timestamp to just the date portion.
1264fn format_date(s: &str) -> String {
1265    s.chars().take(10).collect()
1266}
1267
1268/// Width of a table column: the widest cell, but never narrower than the
1269/// column header, plus a two-space gap.
1270fn col_width(header: &str, cells: impl Iterator<Item = usize>) -> usize {
1271    cells.max().unwrap_or(0).max(header.len()) + 2
1272}
1273
1274/// Render a byte count as a short human-readable size.
1275fn format_size(bytes: u64) -> String {
1276    const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
1277    let mut value = bytes as f64;
1278    let mut unit = 0;
1279    while value >= 1024.0 && unit < UNITS.len() - 1 {
1280        value /= 1024.0;
1281        unit += 1;
1282    }
1283    if unit == 0 {
1284        format!("{bytes} B")
1285    } else {
1286        format!("{value:.1} {}", UNITS[unit])
1287    }
1288}
1289
1290/// Reduce a filename reported by Jira to a single path component, so a crafted
1291/// attachment name cannot write outside the target directory.
1292fn safe_file_name(filename: &str) -> Option<&str> {
1293    let name = filename.rsplit(['/', '\\']).next()?;
1294    if name.is_empty() || name == "." || name == ".." || name.contains(':') {
1295        None
1296    } else {
1297        Some(name)
1298    }
1299}
1300
1301/// Minimum width to clamp narrow terminals to, so fixed columns (key, status,
1302/// assignee, type) still leave at least 20 characters for the summary.
1303const MIN_TERMINAL_WIDTH: usize = 60;
1304
1305/// Fallback width used when neither the TTY nor `COLUMNS` advertises a size -
1306/// matches the historical default.
1307const DEFAULT_TERMINAL_WIDTH: usize = 120;
1308
1309/// Determine the terminal width for rendering the issues table.
1310///
1311/// Query the live TTY first (via `ioctl(TIOCGWINSZ)` / Windows console APIs),
1312/// fall back to `COLUMNS` for non-TTY contexts where the caller still wants
1313/// to pin the width, and finally to a reasonable default.
1314fn terminal_width() -> usize {
1315    use std::io::IsTerminal;
1316
1317    let tty_width = std::io::stdout()
1318        .is_terminal()
1319        .then(terminal_size::terminal_size)
1320        .flatten()
1321        .map(|(terminal_size::Width(w), _)| w as usize);
1322    let columns = std::env::var("COLUMNS").ok().and_then(|v| v.parse().ok());
1323
1324    resolve_terminal_width(tty_width, columns)
1325}
1326
1327/// Pure resolution of the three width sources, in priority order. Extracted so
1328/// the decision logic is testable without mocking the process environment or
1329/// the TTY.
1330fn resolve_terminal_width(tty_width: Option<usize>, columns: Option<usize>) -> usize {
1331    if let Some(w) = tty_width {
1332        return w.max(MIN_TERMINAL_WIDTH);
1333    }
1334    columns.unwrap_or(DEFAULT_TERMINAL_WIDTH)
1335}
1336
1337/// Resolve `--assignee` for create/update into omitted, cleared, or assigned.
1338///
1339/// `--assignee me` triggers a `GET /myself` round-trip to fetch the current user's account ID.
1340/// `--assignee none` or `unassign` returns `Some(None)` (the unassign sentinel).
1341/// `--assignee <id>` returns `Some(Some(id))` (set to the literal account ID).
1342/// `None` (flag absent) returns `None` (keep the create default or existing assignee).
1343pub async fn resolve_assignee_arg(
1344    client: &JiraClient,
1345    arg: Option<&str>,
1346) -> Result<Option<Option<String>>, ApiError> {
1347    match arg {
1348        None => Ok(None),
1349        Some("none" | "unassign") => Ok(Some(None)),
1350        Some("me") => {
1351            let me = client.get_myself().await?;
1352            Ok(Some(Some(me.account_id)))
1353        }
1354        Some(id) => Ok(Some(Some(id.to_string()))),
1355    }
1356}
1357
1358#[cfg(test)]
1359mod tests {
1360    use super::*;
1361    use crate::api::types::{IssueFields, IssueTypeField, StatusField, Version};
1362
1363    fn issue_fixture(fix: Option<Vec<Version>>, aff: Option<Vec<Version>>) -> Issue {
1364        Issue {
1365            id: "10001".into(),
1366            key: "PROJ-1".into(),
1367            url: None,
1368            fields: IssueFields {
1369                summary: "Test".into(),
1370                status: StatusField {
1371                    name: "Open".into(),
1372                },
1373                assignee: None,
1374                reporter: None,
1375                priority: None,
1376                issuetype: IssueTypeField {
1377                    name: "Bug".into(),
1378                    hierarchy_level: None,
1379                },
1380                description: None,
1381                labels: None,
1382                components: None,
1383                fix_versions: fix,
1384                versions: aff,
1385                created: None,
1386                updated: None,
1387                comment: None,
1388                issue_links: None,
1389                parent: None,
1390                extra: serde_json::Map::new(),
1391            },
1392            epic: None,
1393        }
1394    }
1395
1396    fn make_version(id: &str, name: &str) -> Version {
1397        Version {
1398            id: id.into(),
1399            name: name.into(),
1400            description: None,
1401            released: None,
1402            archived: None,
1403            release_date: None,
1404        }
1405    }
1406
1407    #[test]
1408    fn write_issue_detail_renders_fix_versions_line() {
1409        let issue = issue_fixture(
1410            Some(vec![make_version("1", "1.2.0"), make_version("2", "1.3.0")]),
1411            None,
1412        );
1413        let mut buf = Vec::new();
1414        write_issue_detail(&mut buf, &issue).unwrap();
1415        let out = String::from_utf8(buf).unwrap();
1416        assert!(
1417            out.contains("  Fix Versions:     1.2.0, 1.3.0"),
1418            "expected rendered fix-versions line, got:\n{out}"
1419        );
1420    }
1421
1422    #[test]
1423    fn write_issue_detail_renders_affects_versions_line() {
1424        let issue = issue_fixture(None, Some(vec![make_version("5", "1.1.0")]));
1425        let mut buf = Vec::new();
1426        write_issue_detail(&mut buf, &issue).unwrap();
1427        let out = String::from_utf8(buf).unwrap();
1428        assert!(
1429            out.contains("  Affects Versions: 1.1.0"),
1430            "expected affects-versions line, got:\n{out}"
1431        );
1432    }
1433
1434    #[test]
1435    fn write_issue_detail_omits_version_lines_when_empty() {
1436        let issue = issue_fixture(Some(vec![]), None);
1437        let mut buf = Vec::new();
1438        write_issue_detail(&mut buf, &issue).unwrap();
1439        let out = String::from_utf8(buf).unwrap();
1440        assert!(
1441            !out.contains("Fix Versions:"),
1442            "should omit fix versions header for empty slice, got:\n{out}"
1443        );
1444        assert!(
1445            !out.contains("Affects Versions:"),
1446            "should omit affects versions header when None, got:\n{out}"
1447        );
1448    }
1449
1450    #[test]
1451    fn truncate_short_string() {
1452        assert_eq!(truncate("hello", 10), "hello");
1453    }
1454
1455    #[test]
1456    fn truncate_exact_length() {
1457        assert_eq!(truncate("hello", 5), "hello");
1458    }
1459
1460    #[test]
1461    fn truncate_long_string() {
1462        assert_eq!(truncate("hello world", 5), "hello…");
1463    }
1464
1465    #[test]
1466    fn truncate_multibyte_safe() {
1467        let result = truncate("日本語テスト", 3);
1468        assert_eq!(result, "日本語…");
1469    }
1470
1471    #[test]
1472    fn build_list_jql_empty() {
1473        assert_eq!(
1474            build_list_jql(&ListFilters::default()),
1475            "ORDER BY updated DESC"
1476        );
1477    }
1478
1479    #[test]
1480    fn build_list_jql_escapes_quotes() {
1481        let jql = build_list_jql(&ListFilters {
1482            status: Some(r#"Done" OR 1=1"#),
1483            ..Default::default()
1484        });
1485        // The double quote must be backslash-escaped so it cannot break out of the JQL string.
1486        // The resulting clause should be:  status = "Done\" OR 1=1"
1487        assert!(jql.contains(r#"\""#), "double quote must be escaped");
1488        assert!(
1489            jql.contains(r#"status = "Done\""#),
1490            "escaped quote must remain inside the status value string"
1491        );
1492    }
1493
1494    #[test]
1495    fn build_list_jql_project_and_status() {
1496        let jql = build_list_jql(&ListFilters {
1497            project: Some("PROJ"),
1498            status: Some("In Progress"),
1499            ..Default::default()
1500        });
1501        assert!(jql.contains(r#"project = "PROJ""#));
1502        assert!(jql.contains(r#"status = "In Progress""#));
1503    }
1504
1505    #[test]
1506    fn build_list_jql_assignee_me() {
1507        let jql = build_list_jql(&ListFilters {
1508            assignee: Some("me"),
1509            ..Default::default()
1510        });
1511        assert!(jql.contains("currentUser()"));
1512    }
1513
1514    #[test]
1515    fn build_list_jql_issue_type() {
1516        let jql = build_list_jql(&ListFilters {
1517            issue_type: Some("Bug"),
1518            ..Default::default()
1519        });
1520        assert!(jql.contains(r#"issuetype = "Bug""#));
1521    }
1522
1523    #[test]
1524    fn build_list_jql_sprint_active() {
1525        let jql = build_list_jql(&ListFilters {
1526            sprint: Some("active"),
1527            ..Default::default()
1528        });
1529        assert!(jql.contains("sprint in openSprints()"));
1530    }
1531
1532    #[test]
1533    fn build_list_jql_sprint_named() {
1534        let jql = build_list_jql(&ListFilters {
1535            sprint: Some("Sprint 42"),
1536            ..Default::default()
1537        });
1538        assert!(jql.contains(r#"sprint = "Sprint 42""#));
1539    }
1540
1541    #[test]
1542    fn build_list_jql_single_component() {
1543        let jql = build_list_jql(&ListFilters {
1544            components: Some(&["Backend"]),
1545            ..Default::default()
1546        });
1547        assert!(
1548            jql.contains(r#"component = "Backend""#),
1549            "expected single-component clause, got: {jql}"
1550        );
1551    }
1552
1553    #[test]
1554    fn build_list_jql_multiple_components() {
1555        let jql = build_list_jql(&ListFilters {
1556            components: Some(&["Backend", "API"]),
1557            ..Default::default()
1558        });
1559        assert!(
1560            jql.contains(r#"component in ("Backend", "API")"#),
1561            "expected `component in (...)` clause, got: {jql}"
1562        );
1563    }
1564
1565    #[test]
1566    fn build_list_jql_escapes_component_quotes() {
1567        let jql = build_list_jql(&ListFilters {
1568            components: Some(&[r#"weird "name""#]),
1569            ..Default::default()
1570        });
1571        assert!(
1572            jql.contains(r#"component = "weird \"name\"""#),
1573            "expected escaped quotes, got: {jql}"
1574        );
1575    }
1576
1577    #[test]
1578    fn build_list_jql_empty_components_emits_no_clause() {
1579        let jql = build_list_jql(&ListFilters {
1580            components: Some(&[]),
1581            ..Default::default()
1582        });
1583        assert!(
1584            !jql.contains("component"),
1585            "expected no component clause for empty slice, got: {jql}"
1586        );
1587    }
1588
1589    #[test]
1590    fn build_list_jql_single_label() {
1591        let jql = build_list_jql(&ListFilters {
1592            labels: Some(&["backend"]),
1593            ..Default::default()
1594        });
1595        assert!(
1596            jql.contains(r#"labels = "backend""#),
1597            "expected single-label clause, got: {jql}"
1598        );
1599    }
1600
1601    #[test]
1602    fn build_list_jql_multiple_labels() {
1603        let jql = build_list_jql(&ListFilters {
1604            labels: Some(&["backend", "urgent"]),
1605            ..Default::default()
1606        });
1607        assert!(
1608            jql.contains(r#"labels in ("backend", "urgent")"#),
1609            "expected `labels in (...)` clause, got: {jql}"
1610        );
1611    }
1612
1613    #[test]
1614    fn build_list_jql_escapes_label_quotes() {
1615        let jql = build_list_jql(&ListFilters {
1616            labels: Some(&[r#"weird "name""#]),
1617            ..Default::default()
1618        });
1619        assert!(
1620            jql.contains(r#"labels = "weird \"name\"""#),
1621            "expected escaped quotes, got: {jql}"
1622        );
1623    }
1624
1625    #[test]
1626    fn build_list_jql_empty_labels_emits_no_clause() {
1627        let jql = build_list_jql(&ListFilters {
1628            labels: Some(&[]),
1629            ..Default::default()
1630        });
1631        assert!(
1632            !jql.contains("labels"),
1633            "expected no labels clause for empty slice, got: {jql}"
1634        );
1635    }
1636
1637    #[test]
1638    fn build_list_jql_single_fix_version() {
1639        let jql = build_list_jql(&ListFilters {
1640            fix_versions: Some(&["1.2.0"]),
1641            ..Default::default()
1642        });
1643        assert!(
1644            jql.contains(r#"fixVersion = "1.2.0""#),
1645            "expected single fixVersion clause, got: {jql}"
1646        );
1647    }
1648
1649    #[test]
1650    fn build_list_jql_multiple_fix_versions() {
1651        let jql = build_list_jql(&ListFilters {
1652            fix_versions: Some(&["1.2.0", "1.3.0"]),
1653            ..Default::default()
1654        });
1655        assert!(
1656            jql.contains(r#"fixVersion in ("1.2.0", "1.3.0")"#),
1657            "expected fixVersion in (...) clause, got: {jql}"
1658        );
1659    }
1660
1661    #[test]
1662    fn build_list_jql_escapes_fix_version_quotes() {
1663        let jql = build_list_jql(&ListFilters {
1664            fix_versions: Some(&[r#"weird "ver""#]),
1665            ..Default::default()
1666        });
1667        assert!(
1668            jql.contains(r#"fixVersion = "weird \"ver\"""#),
1669            "expected escaped quotes, got: {jql}"
1670        );
1671    }
1672
1673    #[test]
1674    fn build_list_jql_empty_fix_versions_emits_no_clause() {
1675        let jql = build_list_jql(&ListFilters {
1676            fix_versions: Some(&[]),
1677            ..Default::default()
1678        });
1679        assert!(
1680            !jql.contains("fixVersion"),
1681            "expected no fixVersion clause for empty slice, got: {jql}"
1682        );
1683    }
1684
1685    #[test]
1686    fn colorize_status_done_is_green() {
1687        let result = colorize_status("Done", "Done");
1688        assert!(result.contains("Done"));
1689        // Green ANSI escape code starts with \x1b[32m
1690        assert!(result.contains("\x1b["));
1691    }
1692
1693    #[test]
1694    fn colorize_status_unknown_unchanged() {
1695        let result = colorize_status("Backlog", "Backlog");
1696        assert_eq!(result, "Backlog");
1697    }
1698
1699    /// Ensures an environment variable is removed even if the test panics.
1700    struct EnvVarGuard(&'static str);
1701
1702    impl Drop for EnvVarGuard {
1703        fn drop(&mut self) {
1704            unsafe { std::env::remove_var(self.0) }
1705        }
1706    }
1707
1708    #[test]
1709    fn terminal_width_fallback_parses_columns() {
1710        unsafe { std::env::set_var("COLUMNS", "200") };
1711        let _guard = EnvVarGuard("COLUMNS");
1712        assert_eq!(terminal_width(), 200);
1713    }
1714
1715    #[test]
1716    fn resolve_terminal_width_prefers_tty_over_columns() {
1717        assert_eq!(resolve_terminal_width(Some(200), Some(80)), 200);
1718    }
1719
1720    #[test]
1721    fn resolve_terminal_width_clamps_narrow_tty_to_minimum() {
1722        assert_eq!(resolve_terminal_width(Some(40), None), MIN_TERMINAL_WIDTH);
1723    }
1724
1725    #[test]
1726    fn resolve_terminal_width_does_not_clamp_columns_fallback() {
1727        // Users who explicitly pin COLUMNS (e.g. for non-TTY output or tests)
1728        // get exactly what they asked for; only the TTY-measured width is
1729        // clamped.
1730        assert_eq!(resolve_terminal_width(None, Some(40)), 40);
1731    }
1732
1733    #[test]
1734    fn resolve_terminal_width_defaults_when_nothing_available() {
1735        assert_eq!(resolve_terminal_width(None, None), DEFAULT_TERMINAL_WIDTH);
1736    }
1737
1738    #[tokio::test]
1739    async fn resolve_assignee_arg_absent_returns_none() {
1740        let server = wiremock::MockServer::start().await;
1741        let client = crate::api::JiraClient::new(
1742            &server.uri(),
1743            "test@example.com",
1744            "test-token",
1745            crate::api::AuthType::Basic,
1746            3,
1747        )
1748        .unwrap();
1749        let result = resolve_assignee_arg(&client, None).await.unwrap();
1750        assert!(result.is_none());
1751    }
1752
1753    #[tokio::test]
1754    async fn resolve_assignee_arg_none_sentinel_returns_some_none() {
1755        let server = wiremock::MockServer::start().await;
1756        let client = crate::api::JiraClient::new(
1757            &server.uri(),
1758            "test@example.com",
1759            "test-token",
1760            crate::api::AuthType::Basic,
1761            3,
1762        )
1763        .unwrap();
1764        let result = resolve_assignee_arg(&client, Some("none")).await.unwrap();
1765        assert!(matches!(result, Some(None)));
1766    }
1767
1768    #[tokio::test]
1769    async fn resolve_assignee_arg_literal_id_passes_through() {
1770        let server = wiremock::MockServer::start().await;
1771        let client = crate::api::JiraClient::new(
1772            &server.uri(),
1773            "test@example.com",
1774            "test-token",
1775            crate::api::AuthType::Basic,
1776            3,
1777        )
1778        .unwrap();
1779        let result = resolve_assignee_arg(&client, Some("literal-id-999"))
1780            .await
1781            .unwrap();
1782        assert_eq!(result, Some(Some("literal-id-999".to_string())));
1783    }
1784
1785    #[test]
1786    fn fields_names_are_exactly_the_issue_json_keys() {
1787        let client = crate::api::JiraClient::new(
1788            "https://example.atlassian.net",
1789            "test@example.com",
1790            "test-token",
1791            crate::api::AuthType::Basic,
1792            3,
1793        )
1794        .unwrap();
1795        let json = issue_to_json(&issue_fixture(None, None), &client);
1796        let mut keys: Vec<&str> = json
1797            .as_object()
1798            .unwrap()
1799            .keys()
1800            .map(String::as_str)
1801            .collect();
1802        let mut expected = ISSUE_SUMMARY_KEYS.to_vec();
1803        keys.sort_unstable();
1804        expected.sort_unstable();
1805        assert_eq!(keys, expected);
1806    }
1807}
1808
1809#[cfg(test)]
1810mod attachment_filename_tests {
1811    use super::safe_file_name;
1812    use std::path::Path;
1813
1814    /// Jira reports the filename, so it is attacker-influenced whenever an
1815    /// attacker can attach a file to an issue. Whatever comes back must land
1816    /// directly in the requested directory and nowhere else.
1817    #[test]
1818    fn a_crafted_filename_cannot_escape_the_target_directory() {
1819        let hostile = [
1820            "../../etc/passwd",
1821            "/etc/passwd",
1822            "....//....//etc/passwd",
1823            "..\\..\\windows\\system32\\config\\sam",
1824            "foo/../../bar",
1825            "./../../x",
1826            "~/.ssh/authorized_keys",
1827            "a/b/c/deep.txt",
1828        ];
1829        let dir = Path::new("/tmp/jira-cli-downloads");
1830        for name in hostile {
1831            let Some(reduced) = safe_file_name(name) else {
1832                continue;
1833            };
1834            let joined = dir.join(reduced);
1835            assert_eq!(
1836                joined.parent(),
1837                Some(dir),
1838                "{name:?} reduced to {reduced:?}, which lands outside the target directory"
1839            );
1840            assert!(
1841                !reduced.contains('/') && !reduced.contains('\\'),
1842                "{name:?} reduced to {reduced:?}, which is still a path rather than a name"
1843            );
1844        }
1845    }
1846
1847    #[test]
1848    fn names_that_are_not_usable_as_a_file_are_refused() {
1849        for name in ["", ".", "..", "foo/..", "bar/", "C:file.txt"] {
1850            assert_eq!(
1851                safe_file_name(name),
1852                None,
1853                "{name:?} must be refused rather than turned into a filename"
1854            );
1855        }
1856    }
1857
1858    /// The negative control. Without this the test above passes just as happily
1859    /// on a guard that refuses everything, which would break every download.
1860    #[test]
1861    fn an_ordinary_filename_is_passed_through_untouched() {
1862        for name in [
1863            "report.pdf",
1864            ".hidden",
1865            "with space.txt",
1866            "..leading",
1867            "e\u{0301}.png",
1868        ] {
1869            assert_eq!(
1870                safe_file_name(name),
1871                Some(name),
1872                "{name:?} is a perfectly good filename and must not be altered"
1873            );
1874        }
1875    }
1876}