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#[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
72pub 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
85pub 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
145pub 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
216pub 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
291pub 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 &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
433pub 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
464pub 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
484pub 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
498pub 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
523pub 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
585pub 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
604pub 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
650pub 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
664pub 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
765pub async fn bulk_assign(
767 client: &JiraClient,
768 out: &OutputConfig,
769 jql: &str,
770 assignee: &str,
771 dry_run: bool,
772) -> Result<(), ApiError> {
773 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
856pub(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 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
1040fn 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 "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": 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 "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
1186fn 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
1248fn 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
1262fn 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
1279fn 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
1289fn format_date(s: &str) -> String {
1291 s.chars().take(10).collect()
1292}
1293
1294fn col_width(header: &str, cells: impl Iterator<Item = usize>) -> usize {
1297 cells.max().unwrap_or(0).max(header.len()) + 2
1298}
1299
1300fn 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
1316fn 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
1327const MIN_TERMINAL_WIDTH: usize = 60;
1330
1331const DEFAULT_TERMINAL_WIDTH: usize = 120;
1334
1335fn 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
1353fn 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
1363pub 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 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 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 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 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 #[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 #[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}