Skip to main content

ytcli/render/
text.rs

1//! The compact text view.
2//!
3//! Target shape for `issue get`, roughly fifteen lines instead of a five-kilobyte
4//! payload. Links are always present: "what blocks this" is the question that
5//! follows "what is this", and making the caller run a second command for it
6//! costs more than the four lines it saves.
7
8use 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/// Render one issue.
27#[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/// Render the links of an issue on their own.
85///
86/// Same one-line-per-link shape as the compact view, so a caller that has seen
87/// one has seen both.
88#[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        // The id leads, as it does in every other listing whose rows can be
94        // deleted: `issue link delete` takes it, and this is where the help
95        // says to find it.
96        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/// Render the links that leave Tracker.
116///
117/// A table rather than the one-line shape `links` uses: an issue link is
118/// identified by a key the reader already understands, and one of these is
119/// identified by an application they may not, so the application needs a column
120/// of its own rather than a parenthesis.
121#[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        // Written elsewhere, by somebody outside this organisation's Tracker.
128        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/// Render comments, each fenced with its own source.
157///
158/// The fence names the comment and its author, so a reader can tell which part
159/// of the output someone else wrote — the whole point of the marking (ADR 1).
160#[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/// Render an issue's worklog.
199///
200/// The duration is shown the way it is typed — `1h 30m`, not `PT1H30M`. The
201/// ISO form is what `--format json` carries, because that is the API's own
202/// vocabulary and what a script is written against.
203#[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/// Worklog entries from across the organisation.
248///
249/// Different from [`worklogs`] in the one way that matters: the issue is a
250/// column, because here it is the only thing that says what the time was for.
251#[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/// What changed on an issue, one line per field.
292///
293/// The unit is a field, not an event: "who set the status to Closed" is the
294/// question, and an event that touched three fields answers it three times over
295/// only if it is split. The event columns repeat as a result, which is the
296/// price of every line being readable on its own.
297///
298/// An event that changed nothing a caller can see — a transport detail, a
299/// re-index — still gets a line, with `-` for the field. Dropping it would make
300/// the history look like it has gaps.
301#[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            // Minutes, not seconds: two changes in the same second are told
316            // apart by their order, never by this column.
317            |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
365/// The sum of a worklog, in ISO 8601.
366///
367/// Days and weeks are left as they came: Tracker counts a working day as eight
368/// hours and a working week as five days, and quietly turning `P1D` into 24
369/// hours here would produce a total nobody's timesheet agrees with.
370fn 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/// Render an issue's checklist.
423#[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        // The box is the state, and it is the first thing on the line so a
430        // column of them can be read at a glance. A terminal gets the glyph a
431        // person reads faster; a pipe keeps the three characters every script
432        // that ever grepped this output was written against.
433        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    // The tally already carried both numbers; all that was missing was the bar.
460    // `done of total` replaces `{done} done` rather than being added to it: two
461    // ways of saying the same thing on one line is one too many.
462    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
474/// The pinned custom fields, then either all of the rest or a count of them.
475///
476/// An agent gets the count: the set differs per queue, most are empty, and
477/// dumping them makes the view unstable. A terminal gets all of them — the
478/// reason to hide them was never that they are uninteresting.
479fn 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    // The sort is load-bearing, not tidiness. `serde_json::Map` is a BTreeMap
495    // only until some dependency turns on `preserve_order`, at which point it
496    // becomes insertion-ordered and this would start varying with whatever order
497    // Tracker happened to serialise the payload in. Field order is a contract
498    // (ADR 3), so it is enforced here rather than inherited.
499    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
540/// What to call a link.
541///
542/// A recognised type gets our own wording; anything else keeps Tracker's, which
543/// is at least true. The fallback word "link" said nothing.
544fn 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
553/// Links, one per line, always with their type.
554fn 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
577/// The description, marked as somebody else's text and trimmed.
578fn 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
593/// A block of text somebody else wrote, in whichever form its reader can use.
594///
595/// The two audiences need different things from the same guarantee. An agent
596/// needs the boundary to survive being pasted into a prompt, so it gets the
597/// `<untrusted>` fence and the markdown source untouched. A person needs to
598/// read the thing: markdown is rendered, and the boundary becomes a margin bar
599/// on every line — a tag they would have to parse by eye is not a boundary.
600pub(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        // The fence is dimmed and nothing more. Giving someone else's text the
625        // same styling as our own output would let it impersonate the tool.
626        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/// The timers running on this machine.
643///
644/// Elapsed rather than started is the column people read, so it is the one that
645/// is painted; the start is kept beside it because "since when" is the question
646/// a forgotten timer raises. Oldest first, from the store.
647#[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/// Render the transitions available from the current status.
680#[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/// Render only the requested fields, on one line.
704///
705/// The cheapest rung of the ladder: a caller that needs a status does not need
706/// the other fourteen lines. Fields come back in the order they were asked for,
707/// and an unknown or unset field renders as `-` rather than vanishing — a
708/// missing column would silently shift everything after it.
709#[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/// Render a page of issues.
761///
762/// The tally that follows is not decoration. Without it a caller that receives
763/// 25 rows cannot tell a complete answer from a truncated one, and "there are no
764/// open issues" is a worse failure than a few wasted tokens.
765#[must_use]
766pub fn issue_page(page: &Page<Issue>, ctx: &Context) -> String {
767    // The fifth value in each row is the status key. It is never printed —
768    // neither format shows more cells than there are columns — and exists so
769    // the colour can be decided by what a status means rather than by the
770    // language the organisation shows it in.
771    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
802/// Colour a status by what it means, not by the words it is shown in.
803///
804/// This takes the key, not the display name. A queue may invent statuses, and
805/// every organisation shows them in its own language — a Russian one answers
806/// `Закрыт` — so matching on the displayed text worked in English and nowhere
807/// else, silently. Anything not on the well-known list stays unpainted rather
808/// than guessed at.
809fn 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
825/// Critical and blocker are worth noticing; the rest are not worth a colour.
826fn 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    /// The compact view is the contract with every caller: a reordered or
934    /// silently widened field list breaks agents' prompt caches and users'
935    /// scripts alike, so it is pinned here.
936    #[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    /// The terminal form is pinned too, escape codes and all: colour is part of
949    /// what people see, and a change to it should be as visible in review as a
950    /// change to the words.
951    #[test]
952    fn issue_terminal_view_is_stable() {
953        insta::assert_snapshot!(issue(&sample(), &human_ctx()));
954    }
955
956    /// The rule that makes styling safe: same words, same order, same data —
957    /// only escape codes differ. Compared at the same detail level, since a
958    /// terminal is also given more of the issue.
959    #[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        // Human output lists custom fields instead of counting them, so compare
975        // the shared prefix: everything up to that line.
976        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    /// A person reading their own terminal is not paying for context, so the
986    /// description is not cut short there.
987    #[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            // Skip until the terminating letter of the CSI sequence.
1012            for c in chars.by_ref() {
1013                if c.is_ascii_alphabetic() {
1014                    break;
1015                }
1016            }
1017        }
1018        out
1019    }
1020
1021    /// A reference field is one readable word wrapped in sixty of plumbing.
1022    /// Both audiences want the word; the plumbing is still in `--format json`.
1023    #[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    /// An object with nothing to display is printed rather than dropped: a field
1036    /// that silently renders as nothing is worse than an ugly one.
1037    #[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    /// The fence is what an agent uses to tell content from instruction, and it
1044    /// has to survive being pasted into a prompt. Rendering is for the terminal.
1045    #[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    /// A person gets the text, not its syntax — but never without the margin
1062    /// that says who wrote it.
1063    #[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    /// A truncated page that does not say it is truncated is worse than a
1113    /// verbose one: the caller concludes the result set is complete.
1114    #[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    /// Enabling an unrelated feature must not reorder the view. `serde_json`'s
1126    /// map type changes behaviour when any dependency turns on `preserve_order`,
1127    /// which is exactly the kind of silent drift a fixed field order forbids.
1128    #[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    /// A field that is absent must still occupy its place: silently dropping it
1157    /// shifts every column after it for anything parsing the line.
1158    #[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}