1use std::fmt::Write as _;
9
10use crate::api::models::{Change, ChecklistItem, Comment, Issue, Link, Page, User, Worklog};
11use crate::render::style::Palette;
12use crate::render::table::Column;
13use crate::render::{Context, untrusted};
14
15fn who(user: Option<&User>) -> &str {
16 user.and_then(|u| u.login.as_deref().or(u.display.as_deref()))
17 .unwrap_or("-")
18}
19
20fn or_dash(value: Option<&String>) -> &str {
21 value.map_or("-", String::as_str)
22}
23
24#[must_use]
26pub fn issue(issue: &Issue, ctx: &Context) -> String {
27 let mut out = String::with_capacity(512);
28 let paint = ctx.painter();
29 let label = |text: &str| paint.paint(text, Palette::label());
30
31 let _ = writeln!(
32 out,
33 "{} {}",
34 paint.paint(&issue.key, Palette::key()),
35 issue.summary
36 );
37 let _ = writeln!(
38 out,
39 "{} {} {} {} {} {}",
40 label("status:"),
41 status_painted(issue.status.as_deref(), ctx),
42 label("type:"),
43 or_dash(issue.issue_type.as_ref()),
44 label("prio:"),
45 priority_painted(issue.priority.as_deref(), ctx),
46 );
47 let _ = writeln!(
48 out,
49 "{} {} {} {} {} {}",
50 label("assignee:"),
51 who(issue.assignee.as_ref()),
52 label("author:"),
53 who(issue.author.as_ref()),
54 label("queue:"),
55 paint.paint(or_dash(issue.queue.as_ref()), Palette::key()),
56 );
57 let _ = writeln!(
58 out,
59 "{} {} {} {}",
60 label("updated:"),
61 issue
62 .updated_at
63 .map_or_else(|| "-".to_owned(), |ts| ts.to_string()),
64 label("comments:"),
65 issue
66 .comment_count
67 .map_or_else(|| "-".to_owned(), |n| n.to_string()),
68 );
69
70 custom_fields(&mut out, issue, ctx);
71
72 links_section(&mut out, issue, ctx);
73 description_section(&mut out, issue, ctx);
74
75 out
76}
77
78#[must_use]
83pub fn links(key: &str, links: &[Link]) -> String {
84 let mut out = String::with_capacity(links.len() * 40 + 32);
85
86 for link in links {
87 let _ = writeln!(
88 out,
89 "{} {}{}{}",
90 relation_of(link),
91 link.key,
92 link.status
93 .as_ref()
94 .map_or_else(String::new, |status| format!(" [{status}]")),
95 link.summary
96 .as_ref()
97 .map_or_else(String::new, |summary| format!(" {summary}")),
98 );
99 }
100
101 let _ = writeln!(out, "shown {} of {} for {key}", links.len(), links.len());
102 out
103}
104
105#[must_use]
110pub fn comments(key: &str, comments: &[Comment], ctx: &Context) -> String {
111 let mut out = String::with_capacity(comments.len() * 160 + 32);
112 let paint = ctx.painter();
113
114 for comment in comments {
115 let author = who(comment.author.as_ref());
116 let when = comment
117 .created_at
118 .map_or_else(|| "-".to_owned(), |ts| ts.to_string());
119 let _ = writeln!(
120 out,
121 "{}",
122 paint.paint(
123 &format!("--- {} by {author} at {when}", comment.id),
124 Palette::label()
125 )
126 );
127 quoted_block(
128 &mut out,
129 &format!("{key}/comment/{} by {author}", comment.id),
130 &comment.text,
131 0,
132 ctx,
133 );
134 }
135
136 let _ = writeln!(
137 out,
138 "{}",
139 paint.paint(
140 &format!("shown {} of {} for {key}", comments.len(), comments.len()),
141 Palette::label()
142 )
143 );
144 out
145}
146
147#[must_use]
153pub fn worklogs(key: &str, worklogs: &[Worklog], ctx: &Context) -> String {
154 let columns = [
155 Column::whole("ID", 12, Palette::key()),
156 Column::whole("DURATION", 10, anstyle::Style::new()),
157 Column::whole("WHEN", 12, anstyle::Style::new()),
158 Column::new("WHO", 16, anstyle::Style::new()),
159 Column::new("COMMENT", 40, Palette::untrusted()),
160 ];
161
162 let rows: Vec<Vec<String>> = worklogs
163 .iter()
164 .map(|entry| {
165 vec![
166 entry.id.clone(),
167 crate::api::duration::human(&entry.duration),
168 entry.start.map_or_else(
169 || "-".to_owned(),
170 |start| start.to_string().chars().take(10).collect(),
171 ),
172 who(entry.author.as_ref()).to_owned(),
173 entry.comment.clone().unwrap_or_else(|| "-".to_owned()),
174 ]
175 })
176 .collect();
177
178 let mut out = crate::render::table::render(&columns, &rows, ctx);
179 let paint = ctx.painter();
180 let total = crate::api::duration::human(&total_duration(worklogs));
181 let _ = writeln!(
182 out,
183 "{}",
184 paint.paint(
185 &format!(
186 "shown {} of {} for {key} — {total} total",
187 rows.len(),
188 rows.len()
189 ),
190 Palette::label()
191 )
192 );
193 out
194}
195
196#[must_use]
201pub fn worklog_search(entries: &[Worklog], ctx: &Context) -> String {
202 let columns = [
203 Column::whole("ISSUE", 14, Palette::key()),
204 Column::whole("WHEN", 12, anstyle::Style::new()),
205 Column::whole("DURATION", 10, anstyle::Style::new()),
206 Column::new("WHO", 16, anstyle::Style::new()),
207 Column::new("COMMENT", 36, Palette::untrusted()),
208 ];
209
210 let rows: Vec<Vec<String>> = entries
211 .iter()
212 .map(|entry| {
213 vec![
214 entry.issue.clone().unwrap_or_else(|| "-".to_owned()),
215 entry.start.map_or_else(
216 || "-".to_owned(),
217 |start| start.to_string().chars().take(10).collect(),
218 ),
219 crate::api::duration::human(&entry.duration),
220 who(entry.author.as_ref()).to_owned(),
221 entry.comment.clone().unwrap_or_else(|| "-".to_owned()),
222 ]
223 })
224 .collect();
225
226 let mut out = crate::render::table::render(&columns, &rows, ctx);
227 let paint = ctx.painter();
228 let total = crate::api::duration::human(&total_duration(entries));
229 let _ = writeln!(
230 out,
231 "{}",
232 paint.paint(
233 &format!("shown {} of {} — {total} total", rows.len(), rows.len()),
234 Palette::label()
235 )
236 );
237 out
238}
239
240#[must_use]
251pub fn changelog(key: &str, changes: &[Change], ctx: &Context) -> String {
252 let columns = [
253 Column::whole("WHEN", 16, anstyle::Style::new()),
254 Column::new("WHO", 16, anstyle::Style::new()),
255 Column::new("FIELD", 16, Palette::key()),
256 Column::new("FROM", 20, Palette::label()),
257 Column::new("TO", 20, anstyle::Style::new()),
258 ];
259
260 let mut rows: Vec<Vec<String>> = Vec::new();
261 for change in changes {
262 let when = change.at.map_or_else(
263 || "-".to_owned(),
264 |at| at.to_string().chars().take(16).collect(),
267 );
268 let by = who(change.by.as_ref()).to_owned();
269
270 if change.fields.is_empty() {
271 rows.push(vec![
272 when,
273 by,
274 "-".to_owned(),
275 "-".to_owned(),
276 "-".to_owned(),
277 ]);
278 continue;
279 }
280 for field in &change.fields {
281 rows.push(vec![
282 when.clone(),
283 by.clone(),
284 field.field.clone(),
285 field.from.clone().unwrap_or_else(|| "-".to_owned()),
286 field.to.clone().unwrap_or_else(|| "-".to_owned()),
287 ]);
288 }
289 }
290
291 let mut out = crate::render::table::render(&columns, &rows, ctx);
292 let paint = ctx.painter();
293 let _ = writeln!(
294 out,
295 "{}",
296 paint.paint(
297 &format!(
298 "shown {} of {} for {key} — from {} {}",
299 rows.len(),
300 rows.len(),
301 changes.len(),
302 if changes.len() == 1 {
303 "event"
304 } else {
305 "events"
306 }
307 ),
308 Palette::label()
309 )
310 );
311 out
312}
313
314fn total_duration(worklogs: &[Worklog]) -> String {
320 let (mut weeks, mut days, mut hours, mut minutes, mut seconds) = (0u64, 0u64, 0u64, 0u64, 0u64);
321
322 for entry in worklogs {
323 let Some(rest) = entry.duration.strip_prefix('P') else {
324 continue;
325 };
326 let mut number = String::new();
327 for character in rest.chars() {
328 if character.is_ascii_digit() {
329 number.push(character);
330 continue;
331 }
332 let Ok(value) = number.parse::<u64>() else {
333 number.clear();
334 continue;
335 };
336 match character {
337 'W' => weeks += value,
338 'D' => days += value,
339 'H' => hours += value,
340 'M' => minutes += value,
341 'S' => seconds += value,
342 _ => {}
343 }
344 number.clear();
345 }
346 }
347
348 minutes += seconds / 60;
349 seconds %= 60;
350 hours += minutes / 60;
351 minutes %= 60;
352
353 let mut out = String::from("P");
354 for (value, unit) in [(weeks, 'W'), (days, 'D')] {
355 if value > 0 {
356 let _ = write!(out, "{value}{unit}");
357 }
358 }
359 let mut time = String::new();
360 for (value, unit) in [(hours, 'H'), (minutes, 'M'), (seconds, 'S')] {
361 if value > 0 {
362 let _ = write!(time, "{value}{unit}");
363 }
364 }
365 if !time.is_empty() {
366 let _ = write!(out, "T{time}");
367 }
368 if out == "P" { "PT0M".to_owned() } else { out }
369}
370
371#[must_use]
373pub fn checklist(key: &str, items: &[ChecklistItem], ctx: &Context) -> String {
374 let mut out = String::with_capacity(items.len() * 64 + 32);
375 let paint = ctx.painter();
376
377 for item in items {
378 let box_ = if item.checked { "[x]" } else { "[ ]" };
381 let assignee = match item.assignee.as_ref() {
382 Some(user) => format!(" @{}", who(Some(user))),
383 None => String::new(),
384 };
385 let deadline = match item.deadline.as_deref() {
386 Some(date) => format!(" due {date}"),
387 None => String::new(),
388 };
389 let _ = writeln!(
390 out,
391 "{} {} {}{}{}",
392 paint.paint(&item.id, Palette::key()),
393 box_,
394 paint.paint(&item.text, Palette::untrusted()),
395 assignee,
396 deadline
397 );
398 }
399
400 let done = items.iter().filter(|item| item.checked).count();
401 let _ = writeln!(
402 out,
403 "{}",
404 paint.paint(
405 &format!(
406 "shown {} of {} for {key} — {done} done",
407 items.len(),
408 items.len()
409 ),
410 Palette::label()
411 )
412 );
413 out
414}
415
416fn custom_fields(out: &mut String, issue: &Issue, ctx: &Context) {
422 let paint = ctx.painter();
423 let label = |text: &str| paint.paint(text, Palette::label());
424
425 for key in &ctx.extra_fields {
426 if let Some(value) = issue.extra.get(key) {
427 let _ = writeln!(
428 out,
429 "{} {}",
430 label(&format!("{key}:")),
431 compact_value(value)
432 );
433 }
434 }
435
436 let mut unpinned: Vec<&String> = issue
442 .extra
443 .keys()
444 .filter(|key| !ctx.extra_fields.contains(key))
445 .collect();
446 unpinned.sort();
447
448 if unpinned.is_empty() {
449 return;
450 }
451
452 if ctx.is_human() {
453 for key in unpinned {
454 if let Some(value) = issue.extra.get(key) {
455 let _ = writeln!(
456 out,
457 "{} {}",
458 label(&format!("{key}:")),
459 compact_value(value)
460 );
461 }
462 }
463 return;
464 }
465
466 let shown: Vec<&str> = unpinned.iter().take(3).map(|k| k.as_str()).collect();
467 let rest = unpinned.len().saturating_sub(shown.len());
468 let suffix = if rest > 0 {
469 format!(", +{rest}")
470 } else {
471 String::new()
472 };
473 let _ = writeln!(
474 out,
475 "{} {} set ({}{suffix}) — see --fields",
476 label("custom:"),
477 unpinned.len(),
478 shown.join(", "),
479 );
480}
481
482fn relation_of(link: &crate::api::models::Link) -> String {
487 if link.kind == crate::api::models::LinkKind::Other
488 && let Some(relation) = &link.relation
489 {
490 return relation.clone();
491 }
492 link.kind.label().to_owned()
493}
494
495fn links_section(out: &mut String, issue: &Issue, ctx: &Context) {
497 let paint = ctx.painter();
498 let label = |text: &str| paint.paint(text, Palette::label());
499
500 if issue.links.is_empty() {
501 let _ = writeln!(out, "{} none", label("links:"));
502 return;
503 }
504
505 let _ = writeln!(out, "{}", label("links:"));
506 for link in &issue.links {
507 let _ = writeln!(
508 out,
509 " {} {}{}",
510 label(&relation_of(link)),
511 paint.paint(&link.key, Palette::key()),
512 link.status
513 .as_ref()
514 .map_or_else(String::new, |status| format!(" [{status}]")),
515 );
516 }
517}
518
519fn description_section(out: &mut String, issue: &Issue, ctx: &Context) {
521 let Some(description) = issue.description.as_deref().filter(|d| !d.is_empty()) else {
522 return;
523 };
524
525 let (body, withheld) = untrusted::head(description, ctx.description_lines);
526 quoted_block(
527 out,
528 &format!("{}/description", issue.key),
529 &body,
530 withheld,
531 ctx,
532 );
533}
534
535pub(crate) fn quoted_block(
543 out: &mut String,
544 source: &str,
545 body: &str,
546 withheld: usize,
547 ctx: &Context,
548) {
549 let paint = ctx.painter();
550 let label = |text: &str| paint.paint(text, Palette::label());
551
552 if ctx.is_human() {
553 let _ = writeln!(
554 out,
555 "{}",
556 label(&format!("--- {source} (written by Tracker users)"))
557 );
558 out.push_str(&crate::render::markdown::quoted(
559 body,
560 ctx.width,
561 paint,
562 &ctx.inline,
563 ));
564 } else {
565 let _ = writeln!(out, "{}", label("---"));
566 let _ = writeln!(
569 out,
570 "{}",
571 paint.paint(&untrusted::fence(source, body), Palette::untrusted())
572 );
573 }
574
575 if withheld > 0 {
576 let _ = writeln!(
577 out,
578 "{}",
579 label(&format!("(+{withheld} more lines: --full)"))
580 );
581 }
582}
583
584#[must_use]
586pub fn transitions(key: &str, transitions: &[crate::api::Transition]) -> String {
587 let mut out = String::with_capacity(transitions.len() * 40 + 32);
588
589 for transition in transitions {
590 let _ = writeln!(
591 out,
592 "{:<20} {:<24} → {}",
593 transition.id,
594 transition.name,
595 transition.to.as_deref().unwrap_or("-"),
596 );
597 }
598
599 let _ = writeln!(
600 out,
601 "shown {} of {} for {key}",
602 transitions.len(),
603 transitions.len()
604 );
605 out
606}
607
608#[must_use]
615pub fn issue_selected(issue: &Issue, fields: &[String]) -> String {
616 let mut out = String::with_capacity(64 + fields.len() * 24);
617 out.push_str(&issue.key);
618
619 for field in fields {
620 let _ = write!(out, " {field}={}", field_value(issue, field));
621 }
622
623 out.push('\n');
624 out
625}
626
627fn field_value(issue: &Issue, field: &str) -> String {
628 match field {
629 "key" => issue.key.clone(),
630 "summary" => issue.summary.clone(),
631 "status" => or_dash(issue.status.as_ref()).to_owned(),
632 "type" => or_dash(issue.issue_type.as_ref()).to_owned(),
633 "priority" => or_dash(issue.priority.as_ref()).to_owned(),
634 "queue" => or_dash(issue.queue.as_ref()).to_owned(),
635 "assignee" => who(issue.assignee.as_ref()).to_owned(),
636 "author" => who(issue.author.as_ref()).to_owned(),
637 "created" => issue
638 .created_at
639 .map_or_else(|| "-".to_owned(), |ts| ts.to_string()),
640 "updated" => issue
641 .updated_at
642 .map_or_else(|| "-".to_owned(), |ts| ts.to_string()),
643 "comments" => issue
644 .comment_count
645 .map_or_else(|| "-".to_owned(), |n| n.to_string()),
646 "links" => {
647 if issue.links.is_empty() {
648 "none".to_owned()
649 } else {
650 issue
651 .links
652 .iter()
653 .map(|link| format!("{} {}", relation_of(link), link.key))
654 .collect::<Vec<_>>()
655 .join("; ")
656 }
657 }
658 custom => issue
659 .extra
660 .get(custom)
661 .map_or_else(|| "-".to_owned(), compact_value),
662 }
663}
664
665#[must_use]
671pub fn issue_page(page: &Page<Issue>, ctx: &Context) -> String {
672 let columns = [
673 Column::whole("KEY", 12, Palette::key()),
674 Column::by_value("STATUS", 14, status_style),
675 Column::new("ASSIGNEE", 14, anstyle::Style::new()),
676 Column::new("SUMMARY", 60, anstyle::Style::new()),
677 ];
678 let rows: Vec<Vec<String>> = page
679 .items
680 .iter()
681 .map(|issue| {
682 vec![
683 issue.key.clone(),
684 or_dash(issue.status.as_ref()).to_owned(),
685 who(issue.assignee.as_ref()).to_owned(),
686 issue.summary.clone(),
687 ]
688 })
689 .collect();
690
691 let mut out = crate::render::table::render(&columns, &rows, ctx);
692 out.push_str(&crate::render::table::tally(
693 page.items.len(),
694 page.total,
695 page.has_more().then_some(page.page + 1),
696 ctx,
697 ));
698 out
699}
700
701fn status_style(status: &str) -> anstyle::Style {
705 let lower = status.to_ascii_lowercase();
706 if lower.contains("closed") || lower.contains("resolved") || lower.contains("done") {
707 Palette::ok()
708 } else if lower.contains("progress") || lower.contains("review") || lower.contains("test") {
709 Palette::warn()
710 } else {
711 anstyle::Style::new()
712 }
713}
714
715fn status_painted(status: Option<&str>, ctx: &Context) -> String {
716 let Some(status) = status else {
717 return "-".to_owned();
718 };
719 ctx.painter().paint(status, status_style(status))
720}
721
722fn priority_painted(priority: Option<&str>, ctx: &Context) -> String {
724 let paint = ctx.painter();
725 let Some(priority) = priority else {
726 return "-".to_owned();
727 };
728
729 let lower = priority.to_ascii_lowercase();
730 if lower.contains("critical") || lower.contains("blocker") {
731 paint.paint(priority, Palette::bad())
732 } else {
733 priority.to_owned()
734 }
735}
736
737fn compact_value(value: &serde_json::Value) -> String {
738 match value {
739 serde_json::Value::String(s) => s.clone(),
740 serde_json::Value::Array(items) => items
741 .iter()
742 .map(compact_value)
743 .collect::<Vec<_>>()
744 .join(", "),
745 serde_json::Value::Object(fields) => fields
746 .get("display")
747 .or_else(|| fields.get("name"))
748 .or_else(|| fields.get("key"))
749 .or_else(|| fields.get("id"))
750 .map_or_else(|| value.to_string(), compact_value),
751 other => other.to_string(),
752 }
753}
754
755#[cfg(test)]
756#[allow(clippy::expect_used)]
757mod tests {
758 use super::*;
759 use crate::api::models::{Link, LinkKind};
760 use crate::render::{Audience, Format};
761
762 fn ctx() -> Context {
763 Context {
764 format: Format::Text,
765 audience: Audience::Machine,
766 description_lines: Some(2),
767 extra_fields: vec!["storyPoints".to_owned()],
768 width: 80,
769 images: false,
770 inline: crate::render::image::Inline::default(),
771 }
772 }
773
774 fn sample() -> Issue {
775 let mut extra = serde_json::Map::new();
776 extra.insert("storyPoints".to_owned(), serde_json::json!(3));
777 extra.insert("sprint".to_owned(), serde_json::json!("S-12"));
778 extra.insert("component".to_owned(), serde_json::json!("api"));
779 extra.insert("team".to_owned(), serde_json::json!("core"));
780 extra.insert("risk".to_owned(), serde_json::json!("low"));
781
782 Issue {
783 key: "PROJ-1".to_owned(),
784 summary: "Attachments are lost on move".to_owned(),
785 status: Some("In Progress".to_owned()),
786 issue_type: Some("Bug".to_owned()),
787 priority: Some("Critical".to_owned()),
788 queue: Some("PROJ".to_owned()),
789 assignee: Some(User {
790 id: "1".to_owned(),
791 login: Some("ilubenets".to_owned()),
792 display: None,
793 }),
794 author: Some(User {
795 id: "2".to_owned(),
796 login: Some("reporter".to_owned()),
797 display: None,
798 }),
799 created_at: None,
800 updated_at: Some(
801 "2026-08-27T10:00:00Z"
802 .parse::<jiff::Timestamp>()
803 .expect("timestamp"),
804 ),
805 description: Some("line one\nline two\nline three\nline four".to_owned()),
806 links: vec![
807 Link {
808 kind: LinkKind::IsBlockedBy,
809 relation: None,
810 key: "PROJ-3".to_owned(),
811 summary: None,
812 status: Some("Open".to_owned()),
813 },
814 Link {
815 kind: LinkKind::Parent,
816 relation: None,
817 key: "PROJ-9".to_owned(),
818 summary: None,
819 status: None,
820 },
821 ],
822 comment_count: Some(3),
823 extra,
824 }
825 }
826
827 #[test]
831 fn issue_compact_view_is_stable() {
832 insta::assert_snapshot!(issue(&sample(), &ctx()));
833 }
834
835 fn human_ctx() -> Context {
836 Context {
837 audience: Audience::Human,
838 ..ctx()
839 }
840 }
841
842 #[test]
846 fn issue_terminal_view_is_stable() {
847 insta::assert_snapshot!(issue(&sample(), &human_ctx()));
848 }
849
850 #[test]
854 fn styling_changes_nothing_but_the_escape_codes() {
855 let coloured_machine = Context {
856 audience: Audience::Human,
857 ..ctx()
858 };
859 let plain = issue(
860 &sample(),
861 &Context {
862 audience: Audience::Machine,
863 ..coloured_machine.clone()
864 },
865 );
866 let coloured = issue(&sample(), &coloured_machine);
867
868 let cut = |text: &str| {
871 text.lines()
872 .take_while(|line| !line.contains("custom:") && !line.starts_with("component:"))
873 .collect::<Vec<_>>()
874 .join("\n")
875 };
876 assert_eq!(cut(&strip_ansi(&coloured)), cut(&plain));
877 }
878
879 #[test]
882 fn a_terminal_gets_the_whole_description_and_every_custom_field() {
883 let human = Context {
884 audience: Audience::Human,
885 description_lines: None,
886 ..ctx()
887 };
888 let rendered = strip_ansi(&issue(&sample(), &human));
889
890 assert!(rendered.contains("line four"));
891 assert!(!rendered.contains("more lines: --full"));
892 assert!(rendered.contains("component: api"));
893 assert!(rendered.contains("team: core"));
894 assert!(!rendered.contains("custom: "));
895 }
896
897 fn strip_ansi(text: &str) -> String {
898 let mut out = String::with_capacity(text.len());
899 let mut chars = text.chars();
900 while let Some(c) = chars.next() {
901 if c != '\u{1b}' {
902 out.push(c);
903 continue;
904 }
905 for c in chars.by_ref() {
907 if c.is_ascii_alphabetic() {
908 break;
909 }
910 }
911 }
912 out
913 }
914
915 #[test]
918 fn a_reference_field_renders_as_its_display_name() {
919 let value = serde_json::json!([
920 {"display": "Platform: backend", "id": "6", "self": "https://api/6"},
921 {"display": "Platform: frontend", "id": "7", "self": "https://api/7"},
922 ]);
923 assert_eq!(
924 compact_value(&value),
925 "Platform: backend, Platform: frontend"
926 );
927 }
928
929 #[test]
932 fn an_unrecognised_object_still_shows_its_contents() {
933 let value = serde_json::json!({"weird": 1});
934 assert_eq!(compact_value(&value), "{\"weird\":1}");
935 }
936
937 #[test]
940 fn machine_output_keeps_the_fence_and_the_markdown_source() {
941 let mut issue_md = sample();
942 issue_md.description = Some("# Title\n\n**loud**".to_owned());
943 let rendered = issue(
944 &issue_md,
945 &Context {
946 description_lines: None,
947 ..ctx()
948 },
949 );
950 assert!(rendered.contains("<untrusted src=\"PROJ-1/description\""));
951 assert!(rendered.contains("# Title"));
952 assert!(rendered.contains("**loud**"));
953 }
954
955 #[test]
958 fn a_terminal_gets_rendered_markdown_behind_a_margin() {
959 let mut issue_md = sample();
960 issue_md.description = Some("# Title\n\n**loud**".to_owned());
961 let rendered = issue(
962 &issue_md,
963 &Context {
964 audience: Audience::Human,
965 description_lines: None,
966 ..ctx()
967 },
968 );
969 let plain = strip_ansi(&rendered);
970
971 assert!(!plain.contains("<untrusted"));
972 assert!(!plain.contains("**"));
973 assert!(plain.contains("(written by Tracker users)"));
974 assert!(plain.contains("Title"));
975 for line in plain
976 .lines()
977 .skip_while(|l| !l.contains("written by"))
978 .skip(1)
979 {
980 assert!(line.starts_with('\u{258f}'), "unmarked line: {line}");
981 }
982 }
983
984 #[test]
985 fn description_is_fenced_and_trimmed() {
986 let rendered = issue(&sample(), &ctx());
987 assert!(rendered.contains("<untrusted src=\"PROJ-1/description\""));
988 assert!(rendered.contains("(+2 more lines: --full)"));
989 assert!(!rendered.contains("line three"));
990 }
991
992 #[test]
993 fn links_always_carry_their_type() {
994 let rendered = issue(&sample(), &ctx());
995 assert!(rendered.contains("is blocked by PROJ-3 [Open]"));
996 assert!(rendered.contains("parent PROJ-9"));
997 }
998
999 #[test]
1000 fn issue_without_links_says_so_rather_than_omitting_the_line() {
1001 let mut issue_without = sample();
1002 issue_without.links.clear();
1003 assert!(issue(&issue_without, &ctx()).contains("links: none"));
1004 }
1005
1006 #[test]
1009 fn page_reports_totals_and_the_next_page() {
1010 let page = Page {
1011 items: vec![sample()],
1012 page: 1,
1013 per_page: 1,
1014 total: Some(340),
1015 };
1016 insta::assert_snapshot!(issue_page(&page, &ctx()));
1017 }
1018
1019 #[test]
1023 fn custom_field_summary_does_not_depend_on_payload_order() {
1024 let mut shuffled = sample();
1025 let entries: Vec<(String, serde_json::Value)> = vec![
1026 ("team".to_owned(), serde_json::json!("core")),
1027 ("component".to_owned(), serde_json::json!("api")),
1028 ("risk".to_owned(), serde_json::json!("low")),
1029 ("sprint".to_owned(), serde_json::json!("S-12")),
1030 ("storyPoints".to_owned(), serde_json::json!(3)),
1031 ];
1032 shuffled.extra = entries.into_iter().collect();
1033
1034 assert_eq!(issue(&sample(), &ctx()), issue(&shuffled, &ctx()));
1035 }
1036
1037 #[test]
1038 fn selected_fields_keep_the_order_they_were_asked_for() {
1039 let fields = vec![
1040 "status".to_owned(),
1041 "storyPoints".to_owned(),
1042 "assignee".to_owned(),
1043 ];
1044 assert_eq!(
1045 issue_selected(&sample(), &fields),
1046 "PROJ-1 status=In Progress storyPoints=3 assignee=ilubenets\n"
1047 );
1048 }
1049
1050 #[test]
1053 fn an_unknown_field_renders_as_a_dash() {
1054 let fields = vec!["nonsense".to_owned(), "status".to_owned()];
1055 assert_eq!(
1056 issue_selected(&sample(), &fields),
1057 "PROJ-1 nonsense=- status=In Progress\n"
1058 );
1059 }
1060
1061 #[test]
1062 fn complete_page_does_not_offer_a_next_one() {
1063 let page = Page {
1064 items: vec![sample()],
1065 page: 1,
1066 per_page: 25,
1067 total: Some(1),
1068 };
1069 let rendered = issue_page(&page, &ctx());
1070 assert!(rendered.contains("shown 1 of 1"));
1071 assert!(!rendered.contains("next:"));
1072 }
1073}