Skip to main content

jira_cli/commands/
issues.rs

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