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