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