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            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/// Render an issue's worklog.
200///
201/// The duration is shown the way it is typed — `1h 30m`, not `PT1H30M`. The
202/// ISO form is what `--format json` carries, because that is the API's own
203/// vocabulary and what a script is written against.
204#[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/// Worklog entries from across the organisation.
249///
250/// Different from [`worklogs`] in the one way that matters: the issue is a
251/// column, because here it is the only thing that says what the time was for.
252#[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/// What changed on an issue, one line per field.
293///
294/// The unit is a field, not an event: "who set the status to Closed" is the
295/// question, and an event that touched three fields answers it three times over
296/// only if it is split. The event columns repeat as a result, which is the
297/// price of every line being readable on its own.
298///
299/// An event that changed nothing a caller can see — a transport detail, a
300/// re-index — still gets a line, with `-` for the field. Dropping it would make
301/// the history look like it has gaps.
302#[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            // Minutes, not seconds: two changes in the same second are told
317            // apart by their order, never by this column.
318            |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
366/// The sum of a worklog, in ISO 8601.
367///
368/// Days and weeks are left as they came: Tracker counts a working day as eight
369/// hours and a working week as five days, and quietly turning `P1D` into 24
370/// hours here would produce a total nobody's timesheet agrees with.
371fn 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/// Render an issue's checklist.
424#[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        // The box is the state, and it is the first thing on the line so a
431        // column of them can be read at a glance. A terminal gets the glyph a
432        // person reads faster; a pipe keeps the three characters every script
433        // that ever grepped this output was written against.
434        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    // The tally already carried both numbers; all that was missing was the bar.
461    // `done of total` replaces `{done} done` rather than being added to it: two
462    // ways of saying the same thing on one line is one too many.
463    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
475/// The pinned custom fields, then either all of the rest or a count of them.
476///
477/// An agent gets the count: the set differs per queue, most are empty, and
478/// dumping them makes the view unstable. A terminal gets all of them — the
479/// reason to hide them was never that they are uninteresting.
480fn 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    // The sort is load-bearing, not tidiness. `serde_json::Map` is a BTreeMap
496    // only until some dependency turns on `preserve_order`, at which point it
497    // becomes insertion-ordered and this would start varying with whatever order
498    // Tracker happened to serialise the payload in. Field order is a contract
499    // (ADR 3), so it is enforced here rather than inherited.
500    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
541/// What to call a link.
542///
543/// A recognised type gets our own wording; anything else keeps Tracker's, which
544/// is at least true. The fallback word "link" said nothing.
545fn 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
554/// Links, one per line, always with their type.
555fn 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
578/// The description, marked as somebody else's text and trimmed.
579fn 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
595/// A block of text somebody else wrote, in whichever form its reader can use.
596///
597/// The two audiences need different things from the same guarantee. An agent
598/// needs the boundary to survive being pasted into a prompt, so it gets the
599/// `<untrusted>` fence and the markdown source untouched. A person needs to
600/// read the thing: markdown is rendered, and the boundary becomes a margin bar
601/// on every line — a tag they would have to parse by eye is not a boundary.
602pub(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        // The fence is dimmed and nothing more. Giving someone else's text the
628        // same styling as our own output would let it impersonate the tool.
629        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/// The timers running on this machine.
649///
650/// Elapsed rather than started is the column people read, so it is the one that
651/// is painted; the start is kept beside it because "since when" is the question
652/// a forgotten timer raises. Oldest first, from the store.
653#[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/// Render the transitions available from the current status.
686#[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/// Render only the requested fields, on one line.
710///
711/// The cheapest rung of the ladder: a caller that needs a status does not need
712/// the other fourteen lines. Fields come back in the order they were asked for,
713/// and an unknown or unset field renders as `-` rather than vanishing — a
714/// missing column would silently shift everything after it.
715#[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/// Render a page of issues.
767///
768/// The tally that follows is not decoration. Without it a caller that receives
769/// 25 rows cannot tell a complete answer from a truncated one, and "there are no
770/// open issues" is a worse failure than a few wasted tokens.
771#[must_use]
772pub fn issue_page(page: &Page<Issue>, ctx: &Context) -> String {
773    // The fifth value in each row is the status key. It is never printed —
774    // neither format shows more cells than there are columns — and exists so
775    // the colour can be decided by what a status means rather than by the
776    // language the organisation shows it in.
777    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
808/// Colour a status by what it means, not by the words it is shown in.
809///
810/// This takes the key, not the display name. A queue may invent statuses, and
811/// every organisation shows them in its own language — a Russian one answers
812/// `Закрыт` — so matching on the displayed text worked in English and nowhere
813/// else, silently. Anything not on the well-known list stays unpainted rather
814/// than guessed at.
815fn 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
831/// Critical and blocker are worth noticing; the rest are not worth a colour.
832fn 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    /// The compact view is the contract with every caller: a reordered or
940    /// silently widened field list breaks agents' prompt caches and users'
941    /// scripts alike, so it is pinned here.
942    #[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    /// The terminal form is pinned too, escape codes and all: colour is part of
955    /// what people see, and a change to it should be as visible in review as a
956    /// change to the words.
957    #[test]
958    fn issue_terminal_view_is_stable() {
959        insta::assert_snapshot!(issue(&sample(), &human_ctx()));
960    }
961
962    /// The rule that makes styling safe: same words, same order, same data —
963    /// only escape codes differ. Compared at the same detail level, since a
964    /// terminal is also given more of the issue.
965    #[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        // Human output lists custom fields instead of counting them, so compare
981        // the shared prefix: everything up to that line.
982        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    /// A person reading their own terminal is not paying for context, so the
992    /// description is not cut short there.
993    #[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            // Skip until the terminating letter of the CSI sequence.
1018            for c in chars.by_ref() {
1019                if c.is_ascii_alphabetic() {
1020                    break;
1021                }
1022            }
1023        }
1024        out
1025    }
1026
1027    /// A reference field is one readable word wrapped in sixty of plumbing.
1028    /// Both audiences want the word; the plumbing is still in `--format json`.
1029    #[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    /// An object with nothing to display is printed rather than dropped: a field
1042    /// that silently renders as nothing is worse than an ugly one.
1043    #[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    /// The fence is what an agent uses to tell content from instruction, and it
1050    /// has to survive being pasted into a prompt. Rendering is for the terminal.
1051    #[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    /// A person gets the text, not its syntax — but never without the margin
1068    /// that says who wrote it.
1069    #[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    /// A truncated page that does not say it is truncated is worse than a
1119    /// verbose one: the caller concludes the result set is complete.
1120    #[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    /// Enabling an unrelated feature must not reorder the view. `serde_json`'s
1132    /// map type changes behaviour when any dependency turns on `preserve_order`,
1133    /// which is exactly the kind of silent drift a fixed field order forbids.
1134    #[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    /// A field that is absent must still occupy its place: silently dropping it
1163    /// shifts every column after it for anything parsing the line.
1164    #[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}