1use std::fmt::Write as _;
9
10use crate::api::models::{
11 Change, ChecklistItem, Comment, Issue, Link, Page, RemoteLink, User, Worklog,
12};
13use crate::render::style::Palette;
14use crate::render::table::{Column, render as table, tally};
15use crate::render::{Context, untrusted};
16
17fn who(user: Option<&User>) -> &str {
18 user.and_then(|u| u.login.as_deref().or(u.display.as_deref()))
19 .unwrap_or("-")
20}
21
22fn or_dash(value: Option<&String>) -> &str {
23 value.map_or("-", String::as_str)
24}
25
26#[must_use]
28pub fn issue(issue: &Issue, ctx: &Context) -> String {
29 let mut out = String::with_capacity(512);
30 let paint = ctx.painter();
31 let label = |text: &str| paint.paint(text, Palette::label());
32
33 let _ = writeln!(
34 out,
35 "{} {}",
36 paint.paint(&issue.key, Palette::key()),
37 issue.summary
38 );
39 let _ = writeln!(
40 out,
41 "{} {} {} {} {} {}",
42 label("status:"),
43 status_painted(issue.status.as_deref(), issue.status_key.as_deref(), ctx),
44 label("type:"),
45 or_dash(issue.issue_type.as_ref()),
46 label("prio:"),
47 priority_painted(
48 issue.priority.as_deref(),
49 issue.priority_key.as_deref(),
50 ctx
51 ),
52 );
53 let _ = writeln!(
54 out,
55 "{} {} {} {} {} {}",
56 label("assignee:"),
57 who(issue.assignee.as_ref()),
58 label("author:"),
59 who(issue.author.as_ref()),
60 label("queue:"),
61 paint.paint(or_dash(issue.queue.as_ref()), Palette::key()),
62 );
63 let _ = writeln!(
64 out,
65 "{} {} {} {}",
66 label("updated:"),
67 issue
68 .updated_at
69 .map_or_else(|| "-".to_owned(), |ts| ts.to_string()),
70 label("comments:"),
71 issue
72 .comment_count
73 .map_or_else(|| "-".to_owned(), |n| n.to_string()),
74 );
75
76 custom_fields(&mut out, issue, ctx);
77
78 links_section(&mut out, issue, ctx);
79 description_section(&mut out, issue, ctx);
80
81 out
82}
83
84#[must_use]
89pub fn links(key: &str, links: &[Link]) -> String {
90 let mut out = String::with_capacity(links.len() * 40 + 32);
91
92 for link in links {
93 let _ = writeln!(
97 out,
98 "{} {} {}{}{}",
99 link.id,
100 relation_of(link),
101 link.key,
102 link.status
103 .as_ref()
104 .map_or_else(String::new, |status| format!(" [{status}]")),
105 link.summary
106 .as_ref()
107 .map_or_else(String::new, |summary| format!(" {summary}")),
108 );
109 }
110
111 let _ = writeln!(out, "shown {} of {} for {key}", links.len(), links.len());
112 out
113}
114
115#[must_use]
122pub fn remote_links(key: &str, links: &[RemoteLink], ctx: &Context) -> String {
123 let columns = [
124 Column::new("RELATION", 16, Palette::label()),
125 Column::new("APPLICATION", 20, anstyle::Style::new()),
126 Column::whole("KEY", 16, Palette::key()),
127 Column::new("TITLE", 32, Palette::untrusted()),
129 ];
130
131 let rows: Vec<Vec<String>> = links
132 .iter()
133 .map(|link| {
134 vec![
135 link.relation.clone().unwrap_or_else(|| "-".to_owned()),
136 link.application.clone().unwrap_or_else(|| "-".to_owned()),
137 link.key.clone().unwrap_or_else(|| "-".to_owned()),
138 link.title.clone().unwrap_or_else(|| "-".to_owned()),
139 ]
140 })
141 .collect();
142
143 let mut out = crate::render::table::render(&columns, &rows, ctx);
144 let paint = ctx.painter();
145 let _ = writeln!(
146 out,
147 "{}",
148 paint.paint(
149 &format!("shown {} of {} for {key}", links.len(), links.len()),
150 Palette::label()
151 )
152 );
153 out
154}
155
156#[must_use]
161pub fn comments(key: &str, comments: &[Comment], ctx: &Context) -> String {
162 let mut out = String::with_capacity(comments.len() * 160 + 32);
163 let paint = ctx.painter();
164
165 for comment in comments {
166 let author = who(comment.author.as_ref());
167 let when = comment
168 .created_at
169 .map_or_else(|| "-".to_owned(), |ts| ts.to_string());
170 let _ = writeln!(
171 out,
172 "{}",
173 paint.paint(
174 &format!("--- {} by {author} at {when}", comment.id),
175 Palette::label()
176 )
177 );
178 quoted_block(
179 &mut out,
180 &format!("{key}/comment/{} by {author}", comment.id),
181 untrusted::Author::Tracker,
182 &comment.text,
183 0,
184 ctx,
185 );
186 }
187
188 let _ = writeln!(
189 out,
190 "{}",
191 paint.paint(
192 &format!("shown {} of {} for {key}", comments.len(), comments.len()),
193 Palette::label()
194 )
195 );
196 out
197}
198
199#[must_use]
205pub fn worklogs(key: &str, worklogs: &[Worklog], ctx: &Context) -> String {
206 let columns = [
207 Column::whole("ID", 12, Palette::key()),
208 Column::whole("DURATION", 10, anstyle::Style::new()),
209 Column::whole("WHEN", 12, anstyle::Style::new()),
210 Column::new("WHO", 16, anstyle::Style::new()),
211 Column::new("COMMENT", 40, Palette::untrusted()),
212 ];
213
214 let rows: Vec<Vec<String>> = worklogs
215 .iter()
216 .map(|entry| {
217 vec![
218 entry.id.clone(),
219 crate::api::duration::human(&entry.duration),
220 entry.start.map_or_else(
221 || "-".to_owned(),
222 |start| start.to_string().chars().take(10).collect(),
223 ),
224 who(entry.author.as_ref()).to_owned(),
225 entry.comment.clone().unwrap_or_else(|| "-".to_owned()),
226 ]
227 })
228 .collect();
229
230 let mut out = crate::render::table::render(&columns, &rows, ctx);
231 let paint = ctx.painter();
232 let total = crate::api::duration::human(&total_duration(worklogs));
233 let _ = writeln!(
234 out,
235 "{}",
236 paint.paint(
237 &format!(
238 "shown {} of {} for {key} — {total} total",
239 rows.len(),
240 rows.len()
241 ),
242 Palette::label()
243 )
244 );
245 out
246}
247
248#[must_use]
253pub fn worklog_search(entries: &[Worklog], ctx: &Context) -> String {
254 let columns = [
255 Column::whole("ISSUE", 14, Palette::key()),
256 Column::whole("WHEN", 12, anstyle::Style::new()),
257 Column::whole("DURATION", 10, anstyle::Style::new()),
258 Column::new("WHO", 16, anstyle::Style::new()),
259 Column::new("COMMENT", 36, Palette::untrusted()),
260 ];
261
262 let rows: Vec<Vec<String>> = entries
263 .iter()
264 .map(|entry| {
265 vec![
266 entry.issue.clone().unwrap_or_else(|| "-".to_owned()),
267 entry.start.map_or_else(
268 || "-".to_owned(),
269 |start| start.to_string().chars().take(10).collect(),
270 ),
271 crate::api::duration::human(&entry.duration),
272 who(entry.author.as_ref()).to_owned(),
273 entry.comment.clone().unwrap_or_else(|| "-".to_owned()),
274 ]
275 })
276 .collect();
277
278 let mut out = crate::render::table::render(&columns, &rows, ctx);
279 let paint = ctx.painter();
280 let total = crate::api::duration::human(&total_duration(entries));
281 let _ = writeln!(
282 out,
283 "{}",
284 paint.paint(
285 &format!("shown {} of {} — {total} total", rows.len(), rows.len()),
286 Palette::label()
287 )
288 );
289 out
290}
291
292#[must_use]
303pub fn changelog(key: &str, changes: &[Change], ctx: &Context) -> String {
304 let columns = [
305 Column::whole("WHEN", 16, anstyle::Style::new()),
306 Column::new("WHO", 16, anstyle::Style::new()),
307 Column::new("FIELD", 16, Palette::key()),
308 Column::new("FROM", 20, Palette::label()),
309 Column::new("TO", 20, anstyle::Style::new()),
310 ];
311
312 let mut rows: Vec<Vec<String>> = Vec::new();
313 for change in changes {
314 let when = change.at.map_or_else(
315 || "-".to_owned(),
316 |at| at.to_string().chars().take(16).collect(),
319 );
320 let by = who(change.by.as_ref()).to_owned();
321
322 if change.fields.is_empty() {
323 rows.push(vec![
324 when,
325 by,
326 "-".to_owned(),
327 "-".to_owned(),
328 "-".to_owned(),
329 ]);
330 continue;
331 }
332 for field in &change.fields {
333 rows.push(vec![
334 when.clone(),
335 by.clone(),
336 field.field.clone(),
337 field.from.clone().unwrap_or_else(|| "-".to_owned()),
338 field.to.clone().unwrap_or_else(|| "-".to_owned()),
339 ]);
340 }
341 }
342
343 let mut out = crate::render::table::render(&columns, &rows, ctx);
344 let paint = ctx.painter();
345 let _ = writeln!(
346 out,
347 "{}",
348 paint.paint(
349 &format!(
350 "shown {} of {} for {key} — from {} {}",
351 rows.len(),
352 rows.len(),
353 changes.len(),
354 if changes.len() == 1 {
355 "event"
356 } else {
357 "events"
358 }
359 ),
360 Palette::label()
361 )
362 );
363 out
364}
365
366fn total_duration(worklogs: &[Worklog]) -> String {
372 let (mut weeks, mut days, mut hours, mut minutes, mut seconds) = (0u64, 0u64, 0u64, 0u64, 0u64);
373
374 for entry in worklogs {
375 let Some(rest) = entry.duration.strip_prefix('P') else {
376 continue;
377 };
378 let mut number = String::new();
379 for character in rest.chars() {
380 if character.is_ascii_digit() {
381 number.push(character);
382 continue;
383 }
384 let Ok(value) = number.parse::<u64>() else {
385 number.clear();
386 continue;
387 };
388 match character {
389 'W' => weeks += value,
390 'D' => days += value,
391 'H' => hours += value,
392 'M' => minutes += value,
393 'S' => seconds += value,
394 _ => {}
395 }
396 number.clear();
397 }
398 }
399
400 minutes += seconds / 60;
401 seconds %= 60;
402 hours += minutes / 60;
403 minutes %= 60;
404
405 let mut out = String::from("P");
406 for (value, unit) in [(weeks, 'W'), (days, 'D')] {
407 if value > 0 {
408 let _ = write!(out, "{value}{unit}");
409 }
410 }
411 let mut time = String::new();
412 for (value, unit) in [(hours, 'H'), (minutes, 'M'), (seconds, 'S')] {
413 if value > 0 {
414 let _ = write!(time, "{value}{unit}");
415 }
416 }
417 if !time.is_empty() {
418 let _ = write!(out, "T{time}");
419 }
420 if out == "P" { "PT0M".to_owned() } else { out }
421}
422
423#[must_use]
425pub fn checklist(key: &str, items: &[ChecklistItem], ctx: &Context) -> String {
426 let mut out = String::with_capacity(items.len() * 64 + 32);
427 let paint = ctx.painter();
428
429 for item in items {
430 let box_ = match (item.checked, ctx.is_human()) {
435 (true, true) => "\u{2713}",
436 (false, true) => "\u{25cb}",
437 (true, false) => "[x]",
438 (false, false) => "[ ]",
439 };
440 let assignee = match item.assignee.as_ref() {
441 Some(user) => format!(" @{}", who(Some(user))),
442 None => String::new(),
443 };
444 let deadline = match item.deadline.as_deref() {
445 Some(date) => format!(" due {date}"),
446 None => String::new(),
447 };
448 let _ = writeln!(
449 out,
450 "{} {} {}{}{}",
451 paint.paint(&item.id, Palette::key()),
452 box_,
453 paint.paint(&item.text, Palette::untrusted()),
454 assignee,
455 deadline
456 );
457 }
458
459 let done = items.iter().filter(|item| item.checked).count();
460 let _ = writeln!(
464 out,
465 "{} — {} done",
466 paint.paint(
467 &format!("shown {} of {} for {key}", items.len(), items.len()),
468 Palette::label()
469 ),
470 crate::render::bar::ratio(done as u64, items.len() as u64, ctx),
471 );
472 out
473}
474
475fn custom_fields(out: &mut String, issue: &Issue, ctx: &Context) {
481 let paint = ctx.painter();
482 let label = |text: &str| paint.paint(text, Palette::label());
483
484 for key in &ctx.extra_fields {
485 if let Some(value) = issue.extra.get(key) {
486 let _ = writeln!(
487 out,
488 "{} {}",
489 label(&format!("{key}:")),
490 compact_value(value)
491 );
492 }
493 }
494
495 let mut unpinned: Vec<&String> = issue
501 .extra
502 .keys()
503 .filter(|key| !ctx.extra_fields.contains(key))
504 .collect();
505 unpinned.sort();
506
507 if unpinned.is_empty() {
508 return;
509 }
510
511 if ctx.is_human() {
512 for key in unpinned {
513 if let Some(value) = issue.extra.get(key) {
514 let _ = writeln!(
515 out,
516 "{} {}",
517 label(&format!("{key}:")),
518 compact_value(value)
519 );
520 }
521 }
522 return;
523 }
524
525 let shown: Vec<&str> = unpinned.iter().take(3).map(|k| k.as_str()).collect();
526 let rest = unpinned.len().saturating_sub(shown.len());
527 let suffix = if rest > 0 {
528 format!(", +{rest}")
529 } else {
530 String::new()
531 };
532 let _ = writeln!(
533 out,
534 "{} {} set ({}{suffix}) — see --fields",
535 label("custom:"),
536 unpinned.len(),
537 shown.join(", "),
538 );
539}
540
541fn relation_of(link: &crate::api::models::Link) -> String {
546 if link.kind == crate::api::models::LinkKind::Other
547 && let Some(relation) = &link.relation
548 {
549 return relation.clone();
550 }
551 link.kind.label().to_owned()
552}
553
554fn links_section(out: &mut String, issue: &Issue, ctx: &Context) {
556 let paint = ctx.painter();
557 let label = |text: &str| paint.paint(text, Palette::label());
558
559 if issue.links.is_empty() {
560 let _ = writeln!(out, "{} none", label("links:"));
561 return;
562 }
563
564 let _ = writeln!(out, "{}", label("links:"));
565 for link in &issue.links {
566 let _ = writeln!(
567 out,
568 " {} {}{}",
569 label(&relation_of(link)),
570 paint.paint(&link.key, Palette::key()),
571 link.status
572 .as_ref()
573 .map_or_else(String::new, |status| format!(" [{status}]")),
574 );
575 }
576}
577
578fn description_section(out: &mut String, issue: &Issue, ctx: &Context) {
580 let Some(description) = issue.description.as_deref().filter(|d| !d.is_empty()) else {
581 return;
582 };
583
584 let (body, withheld) = untrusted::head(description, ctx.description_lines);
585 quoted_block(
586 out,
587 &format!("{}/description", issue.key),
588 untrusted::Author::Tracker,
589 &body,
590 withheld,
591 ctx,
592 );
593}
594
595pub(crate) fn quoted_block(
603 out: &mut String,
604 source: &str,
605 author: untrusted::Author,
606 body: &str,
607 withheld: usize,
608 ctx: &Context,
609) {
610 let paint = ctx.painter();
611 let label = |text: &str| paint.paint(text, Palette::label());
612
613 if ctx.is_human() {
614 let _ = writeln!(
615 out,
616 "{}",
617 label(&format!("--- {source} (written by {})", author.who()))
618 );
619 out.push_str(&crate::render::markdown::quoted(
620 body,
621 ctx.width,
622 paint,
623 &ctx.inline,
624 ));
625 } else {
626 let _ = writeln!(out, "{}", label("---"));
627 let _ = writeln!(
630 out,
631 "{}",
632 paint.paint(
633 &untrusted::fence(source, author, body),
634 Palette::untrusted()
635 )
636 );
637 }
638
639 if withheld > 0 {
640 let _ = writeln!(
641 out,
642 "{}",
643 label(&format!("(+{withheld} more lines: --full)"))
644 );
645 }
646}
647
648#[must_use]
654pub fn timers(
655 entries: &[&crate::config::timers::Entry],
656 now: jiff::Timestamp,
657 ctx: &Context,
658) -> String {
659 let columns = [
660 Column::whole("KEY", 14, Palette::key()),
661 Column::new("PROFILE", 16, Palette::label()),
662 Column::whole("ELAPSED", 10, Palette::warn()),
663 Column::whole("SINCE", 22, anstyle::Style::new()),
664 ];
665 let rows: Vec<Vec<String>> = entries
666 .iter()
667 .map(|entry| {
668 let elapsed = now.since(entry.started).unwrap_or_default();
669 vec![
670 entry.key.clone(),
671 entry.profile.clone(),
672 crate::api::duration::human(&crate::api::duration::from_minutes(
673 elapsed.get_minutes(),
674 )),
675 entry.started.to_string(),
676 ]
677 })
678 .collect();
679
680 let mut out = table(&columns, &rows, ctx);
681 out.push_str(&tally(entries.len(), Some(entries.len() as u64), None, ctx));
682 out
683}
684
685#[must_use]
687pub fn transitions(key: &str, transitions: &[crate::api::Transition]) -> String {
688 let mut out = String::with_capacity(transitions.len() * 40 + 32);
689
690 for transition in transitions {
691 let _ = writeln!(
692 out,
693 "{:<20} {:<24} → {}",
694 transition.id,
695 transition.name,
696 transition.to.as_deref().unwrap_or("-"),
697 );
698 }
699
700 let _ = writeln!(
701 out,
702 "shown {} of {} for {key}",
703 transitions.len(),
704 transitions.len()
705 );
706 out
707}
708
709#[must_use]
716pub fn issue_selected(issue: &Issue, fields: &[String]) -> String {
717 let mut out = String::with_capacity(64 + fields.len() * 24);
718 out.push_str(&issue.key);
719
720 for field in fields {
721 let _ = write!(out, " {field}={}", field_value(issue, field));
722 }
723
724 out.push('\n');
725 out
726}
727
728fn field_value(issue: &Issue, field: &str) -> String {
729 match field {
730 "key" => issue.key.clone(),
731 "summary" => issue.summary.clone(),
732 "status" => or_dash(issue.status.as_ref()).to_owned(),
733 "type" => or_dash(issue.issue_type.as_ref()).to_owned(),
734 "priority" => or_dash(issue.priority.as_ref()).to_owned(),
735 "queue" => or_dash(issue.queue.as_ref()).to_owned(),
736 "assignee" => who(issue.assignee.as_ref()).to_owned(),
737 "author" => who(issue.author.as_ref()).to_owned(),
738 "created" => issue
739 .created_at
740 .map_or_else(|| "-".to_owned(), |ts| ts.to_string()),
741 "updated" => issue
742 .updated_at
743 .map_or_else(|| "-".to_owned(), |ts| ts.to_string()),
744 "comments" => issue
745 .comment_count
746 .map_or_else(|| "-".to_owned(), |n| n.to_string()),
747 "links" => {
748 if issue.links.is_empty() {
749 "none".to_owned()
750 } else {
751 issue
752 .links
753 .iter()
754 .map(|link| format!("{} {}", relation_of(link), link.key))
755 .collect::<Vec<_>>()
756 .join("; ")
757 }
758 }
759 custom => issue
760 .extra
761 .get(custom)
762 .map_or_else(|| "-".to_owned(), compact_value),
763 }
764}
765
766#[must_use]
772pub fn issue_page(page: &Page<Issue>, ctx: &Context) -> String {
773 const STATUS_KEY: usize = 4;
778 let columns = [
779 Column::whole("KEY", 12, Palette::key()),
780 Column::by_other("STATUS", 14, STATUS_KEY, status_style),
781 Column::new("ASSIGNEE", 14, anstyle::Style::new()),
782 Column::new("SUMMARY", 60, anstyle::Style::new()),
783 ];
784 let rows: Vec<Vec<String>> = page
785 .items
786 .iter()
787 .map(|issue| {
788 vec![
789 issue.key.clone(),
790 or_dash(issue.status.as_ref()).to_owned(),
791 who(issue.assignee.as_ref()).to_owned(),
792 issue.summary.clone(),
793 issue.status_key.clone().unwrap_or_default(),
794 ]
795 })
796 .collect();
797
798 let mut out = crate::render::table::render(&columns, &rows, ctx);
799 out.push_str(&crate::render::table::tally(
800 page.items.len(),
801 page.total,
802 page.has_more().then_some(page.page + 1),
803 ctx,
804 ));
805 out
806}
807
808fn status_style(key: &str) -> anstyle::Style {
816 match key {
817 "closed" | "resolved" | "done" | "released" | "rejected" => Palette::ok(),
818 "inProgress" | "readyForReview" | "inReview" | "testing" | "needInfo" => Palette::warn(),
819 _ => anstyle::Style::new(),
820 }
821}
822
823fn status_painted(status: Option<&str>, key: Option<&str>, ctx: &Context) -> String {
824 let Some(status) = status else {
825 return "-".to_owned();
826 };
827 let style = key.map_or_else(anstyle::Style::new, status_style);
828 ctx.painter().paint(status, style)
829}
830
831fn priority_painted(priority: Option<&str>, key: Option<&str>, ctx: &Context) -> String {
833 let paint = ctx.painter();
834 let Some(priority) = priority else {
835 return "-".to_owned();
836 };
837
838 if matches!(key, Some("critical" | "blocker")) {
839 paint.paint(priority, Palette::bad())
840 } else {
841 priority.to_owned()
842 }
843}
844
845fn compact_value(value: &serde_json::Value) -> String {
846 match value {
847 serde_json::Value::String(s) => s.clone(),
848 serde_json::Value::Array(items) => items
849 .iter()
850 .map(compact_value)
851 .collect::<Vec<_>>()
852 .join(", "),
853 serde_json::Value::Object(fields) => fields
854 .get("display")
855 .or_else(|| fields.get("name"))
856 .or_else(|| fields.get("key"))
857 .or_else(|| fields.get("id"))
858 .map_or_else(|| value.to_string(), compact_value),
859 other => other.to_string(),
860 }
861}
862
863#[cfg(test)]
864#[allow(clippy::expect_used)]
865mod tests {
866 use super::*;
867 use crate::api::models::{Link, LinkKind};
868 use crate::render::{Audience, Format};
869
870 fn ctx() -> Context {
871 Context {
872 format: Format::Text,
873 audience: Audience::Machine,
874 description_lines: Some(2),
875 extra_fields: vec!["storyPoints".to_owned()],
876 width: 80,
877 images: false,
878 inline: crate::render::image::Inline::default(),
879 }
880 }
881
882 fn sample() -> Issue {
883 let mut extra = serde_json::Map::new();
884 extra.insert("storyPoints".to_owned(), serde_json::json!(3));
885 extra.insert("sprint".to_owned(), serde_json::json!("S-12"));
886 extra.insert("component".to_owned(), serde_json::json!("api"));
887 extra.insert("team".to_owned(), serde_json::json!("core"));
888 extra.insert("risk".to_owned(), serde_json::json!("low"));
889
890 Issue {
891 key: "PROJ-1".to_owned(),
892 summary: "Attachments are lost on move".to_owned(),
893 status: Some("In Progress".to_owned()),
894 status_key: Some("inProgress".to_owned()),
895 issue_type: Some("Bug".to_owned()),
896 priority: Some("Critical".to_owned()),
897 priority_key: Some("critical".to_owned()),
898 queue: Some("PROJ".to_owned()),
899 assignee: Some(User {
900 id: "1".to_owned(),
901 login: Some("ilubenets".to_owned()),
902 display: None,
903 }),
904 author: Some(User {
905 id: "2".to_owned(),
906 login: Some("reporter".to_owned()),
907 display: None,
908 }),
909 created_at: None,
910 updated_at: Some(
911 "2026-08-27T10:00:00Z"
912 .parse::<jiff::Timestamp>()
913 .expect("timestamp"),
914 ),
915 description: Some("line one\nline two\nline three\nline four".to_owned()),
916 links: vec![
917 Link {
918 id: "101".to_owned(),
919 kind: LinkKind::IsBlockedBy,
920 relation: None,
921 key: "PROJ-3".to_owned(),
922 summary: None,
923 status: Some("Open".to_owned()),
924 },
925 Link {
926 id: "102".to_owned(),
927 kind: LinkKind::Parent,
928 relation: None,
929 key: "PROJ-9".to_owned(),
930 summary: None,
931 status: None,
932 },
933 ],
934 comment_count: Some(3),
935 extra,
936 }
937 }
938
939 #[test]
943 fn issue_compact_view_is_stable() {
944 insta::assert_snapshot!(issue(&sample(), &ctx()));
945 }
946
947 fn human_ctx() -> Context {
948 Context {
949 audience: Audience::Human,
950 ..ctx()
951 }
952 }
953
954 #[test]
958 fn issue_terminal_view_is_stable() {
959 insta::assert_snapshot!(issue(&sample(), &human_ctx()));
960 }
961
962 #[test]
966 fn styling_changes_nothing_but_the_escape_codes() {
967 let coloured_machine = Context {
968 audience: Audience::Human,
969 ..ctx()
970 };
971 let plain = issue(
972 &sample(),
973 &Context {
974 audience: Audience::Machine,
975 ..coloured_machine.clone()
976 },
977 );
978 let coloured = issue(&sample(), &coloured_machine);
979
980 let cut = |text: &str| {
983 text.lines()
984 .take_while(|line| !line.contains("custom:") && !line.starts_with("component:"))
985 .collect::<Vec<_>>()
986 .join("\n")
987 };
988 assert_eq!(cut(&strip_ansi(&coloured)), cut(&plain));
989 }
990
991 #[test]
994 fn a_terminal_gets_the_whole_description_and_every_custom_field() {
995 let human = Context {
996 audience: Audience::Human,
997 description_lines: None,
998 ..ctx()
999 };
1000 let rendered = strip_ansi(&issue(&sample(), &human));
1001
1002 assert!(rendered.contains("line four"));
1003 assert!(!rendered.contains("more lines: --full"));
1004 assert!(rendered.contains("component: api"));
1005 assert!(rendered.contains("team: core"));
1006 assert!(!rendered.contains("custom: "));
1007 }
1008
1009 fn strip_ansi(text: &str) -> String {
1010 let mut out = String::with_capacity(text.len());
1011 let mut chars = text.chars();
1012 while let Some(c) = chars.next() {
1013 if c != '\u{1b}' {
1014 out.push(c);
1015 continue;
1016 }
1017 for c in chars.by_ref() {
1019 if c.is_ascii_alphabetic() {
1020 break;
1021 }
1022 }
1023 }
1024 out
1025 }
1026
1027 #[test]
1030 fn a_reference_field_renders_as_its_display_name() {
1031 let value = serde_json::json!([
1032 {"display": "Platform: backend", "id": "6", "self": "https://api/6"},
1033 {"display": "Platform: frontend", "id": "7", "self": "https://api/7"},
1034 ]);
1035 assert_eq!(
1036 compact_value(&value),
1037 "Platform: backend, Platform: frontend"
1038 );
1039 }
1040
1041 #[test]
1044 fn an_unrecognised_object_still_shows_its_contents() {
1045 let value = serde_json::json!({"weird": 1});
1046 assert_eq!(compact_value(&value), "{\"weird\":1}");
1047 }
1048
1049 #[test]
1052 fn machine_output_keeps_the_fence_and_the_markdown_source() {
1053 let mut issue_md = sample();
1054 issue_md.description = Some("# Title\n\n**loud**".to_owned());
1055 let rendered = issue(
1056 &issue_md,
1057 &Context {
1058 description_lines: None,
1059 ..ctx()
1060 },
1061 );
1062 assert!(rendered.contains("<untrusted src=\"PROJ-1/description\""));
1063 assert!(rendered.contains("# Title"));
1064 assert!(rendered.contains("**loud**"));
1065 }
1066
1067 #[test]
1070 fn a_terminal_gets_rendered_markdown_behind_a_margin() {
1071 let mut issue_md = sample();
1072 issue_md.description = Some("# Title\n\n**loud**".to_owned());
1073 let rendered = issue(
1074 &issue_md,
1075 &Context {
1076 audience: Audience::Human,
1077 description_lines: None,
1078 ..ctx()
1079 },
1080 );
1081 let plain = strip_ansi(&rendered);
1082
1083 assert!(!plain.contains("<untrusted"));
1084 assert!(!plain.contains("**"));
1085 assert!(plain.contains("(written by Tracker users)"));
1086 assert!(plain.contains("Title"));
1087 for line in plain
1088 .lines()
1089 .skip_while(|l| !l.contains("written by"))
1090 .skip(1)
1091 {
1092 assert!(line.starts_with('\u{258f}'), "unmarked line: {line}");
1093 }
1094 }
1095
1096 #[test]
1097 fn description_is_fenced_and_trimmed() {
1098 let rendered = issue(&sample(), &ctx());
1099 assert!(rendered.contains("<untrusted src=\"PROJ-1/description\""));
1100 assert!(rendered.contains("(+2 more lines: --full)"));
1101 assert!(!rendered.contains("line three"));
1102 }
1103
1104 #[test]
1105 fn links_always_carry_their_type() {
1106 let rendered = issue(&sample(), &ctx());
1107 assert!(rendered.contains("is blocked by PROJ-3 [Open]"));
1108 assert!(rendered.contains("parent PROJ-9"));
1109 }
1110
1111 #[test]
1112 fn issue_without_links_says_so_rather_than_omitting_the_line() {
1113 let mut issue_without = sample();
1114 issue_without.links.clear();
1115 assert!(issue(&issue_without, &ctx()).contains("links: none"));
1116 }
1117
1118 #[test]
1121 fn page_reports_totals_and_the_next_page() {
1122 let page = Page {
1123 items: vec![sample()],
1124 page: 1,
1125 per_page: 1,
1126 total: Some(340),
1127 };
1128 insta::assert_snapshot!(issue_page(&page, &ctx()));
1129 }
1130
1131 #[test]
1135 fn custom_field_summary_does_not_depend_on_payload_order() {
1136 let mut shuffled = sample();
1137 let entries: Vec<(String, serde_json::Value)> = vec![
1138 ("team".to_owned(), serde_json::json!("core")),
1139 ("component".to_owned(), serde_json::json!("api")),
1140 ("risk".to_owned(), serde_json::json!("low")),
1141 ("sprint".to_owned(), serde_json::json!("S-12")),
1142 ("storyPoints".to_owned(), serde_json::json!(3)),
1143 ];
1144 shuffled.extra = entries.into_iter().collect();
1145
1146 assert_eq!(issue(&sample(), &ctx()), issue(&shuffled, &ctx()));
1147 }
1148
1149 #[test]
1150 fn selected_fields_keep_the_order_they_were_asked_for() {
1151 let fields = vec![
1152 "status".to_owned(),
1153 "storyPoints".to_owned(),
1154 "assignee".to_owned(),
1155 ];
1156 assert_eq!(
1157 issue_selected(&sample(), &fields),
1158 "PROJ-1 status=In Progress storyPoints=3 assignee=ilubenets\n"
1159 );
1160 }
1161
1162 #[test]
1165 fn an_unknown_field_renders_as_a_dash() {
1166 let fields = vec!["nonsense".to_owned(), "status".to_owned()];
1167 assert_eq!(
1168 issue_selected(&sample(), &fields),
1169 "PROJ-1 nonsense=- status=In Progress\n"
1170 );
1171 }
1172
1173 #[test]
1174 fn complete_page_does_not_offer_a_next_one() {
1175 let page = Page {
1176 items: vec![sample()],
1177 page: 1,
1178 per_page: 25,
1179 total: Some(1),
1180 };
1181 let rendered = issue_page(&page, &ctx());
1182 assert!(rendered.contains("shown 1 of 1"));
1183 assert!(!rendered.contains("next:"));
1184 }
1185}