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::{Change, ChecklistItem, Comment, Issue, Link, Page, User, Worklog};
11use crate::render::style::Palette;
12use crate::render::table::Column;
13use crate::render::{Context, untrusted};
14
15fn who(user: Option<&User>) -> &str {
16    user.and_then(|u| u.login.as_deref().or(u.display.as_deref()))
17        .unwrap_or("-")
18}
19
20fn or_dash(value: Option<&String>) -> &str {
21    value.map_or("-", String::as_str)
22}
23
24/// Render one issue.
25#[must_use]
26pub fn issue(issue: &Issue, ctx: &Context) -> String {
27    let mut out = String::with_capacity(512);
28    let paint = ctx.painter();
29    let label = |text: &str| paint.paint(text, Palette::label());
30
31    let _ = writeln!(
32        out,
33        "{}  {}",
34        paint.paint(&issue.key, Palette::key()),
35        issue.summary
36    );
37    let _ = writeln!(
38        out,
39        "{} {}   {} {}   {} {}",
40        label("status:"),
41        status_painted(issue.status.as_deref(), ctx),
42        label("type:"),
43        or_dash(issue.issue_type.as_ref()),
44        label("prio:"),
45        priority_painted(issue.priority.as_deref(), ctx),
46    );
47    let _ = writeln!(
48        out,
49        "{} {}   {} {}   {} {}",
50        label("assignee:"),
51        who(issue.assignee.as_ref()),
52        label("author:"),
53        who(issue.author.as_ref()),
54        label("queue:"),
55        paint.paint(or_dash(issue.queue.as_ref()), Palette::key()),
56    );
57    let _ = writeln!(
58        out,
59        "{} {}   {} {}",
60        label("updated:"),
61        issue
62            .updated_at
63            .map_or_else(|| "-".to_owned(), |ts| ts.to_string()),
64        label("comments:"),
65        issue
66            .comment_count
67            .map_or_else(|| "-".to_owned(), |n| n.to_string()),
68    );
69
70    custom_fields(&mut out, issue, ctx);
71
72    links_section(&mut out, issue, ctx);
73    description_section(&mut out, issue, ctx);
74
75    out
76}
77
78/// Render the links of an issue on their own.
79///
80/// Same one-line-per-link shape as the compact view, so a caller that has seen
81/// one has seen both.
82#[must_use]
83pub fn links(key: &str, links: &[Link]) -> String {
84    let mut out = String::with_capacity(links.len() * 40 + 32);
85
86    for link in links {
87        let _ = writeln!(
88            out,
89            "{} {}{}{}",
90            relation_of(link),
91            link.key,
92            link.status
93                .as_ref()
94                .map_or_else(String::new, |status| format!(" [{status}]")),
95            link.summary
96                .as_ref()
97                .map_or_else(String::new, |summary| format!("  {summary}")),
98        );
99    }
100
101    let _ = writeln!(out, "shown {} of {} for {key}", links.len(), links.len());
102    out
103}
104
105/// Render comments, each fenced with its own source.
106///
107/// The fence names the comment and its author, so a reader can tell which part
108/// of the output someone else wrote — the whole point of the marking (ADR 1).
109#[must_use]
110pub fn comments(key: &str, comments: &[Comment], ctx: &Context) -> String {
111    let mut out = String::with_capacity(comments.len() * 160 + 32);
112    let paint = ctx.painter();
113
114    for comment in comments {
115        let author = who(comment.author.as_ref());
116        let when = comment
117            .created_at
118            .map_or_else(|| "-".to_owned(), |ts| ts.to_string());
119        let _ = writeln!(
120            out,
121            "{}",
122            paint.paint(
123                &format!("--- {} by {author} at {when}", comment.id),
124                Palette::label()
125            )
126        );
127        quoted_block(
128            &mut out,
129            &format!("{key}/comment/{} by {author}", comment.id),
130            &comment.text,
131            0,
132            ctx,
133        );
134    }
135
136    let _ = writeln!(
137        out,
138        "{}",
139        paint.paint(
140            &format!("shown {} of {} for {key}", comments.len(), comments.len()),
141            Palette::label()
142        )
143    );
144    out
145}
146
147/// Render an issue's worklog.
148///
149/// The duration is shown the way it is typed — `1h 30m`, not `PT1H30M`. The
150/// ISO form is what `--format json` carries, because that is the API's own
151/// vocabulary and what a script is written against.
152#[must_use]
153pub fn worklogs(key: &str, worklogs: &[Worklog], ctx: &Context) -> String {
154    let columns = [
155        Column::whole("ID", 12, Palette::key()),
156        Column::whole("DURATION", 10, anstyle::Style::new()),
157        Column::whole("WHEN", 12, anstyle::Style::new()),
158        Column::new("WHO", 16, anstyle::Style::new()),
159        Column::new("COMMENT", 40, Palette::untrusted()),
160    ];
161
162    let rows: Vec<Vec<String>> = worklogs
163        .iter()
164        .map(|entry| {
165            vec![
166                entry.id.clone(),
167                crate::api::duration::human(&entry.duration),
168                entry.start.map_or_else(
169                    || "-".to_owned(),
170                    |start| start.to_string().chars().take(10).collect(),
171                ),
172                who(entry.author.as_ref()).to_owned(),
173                entry.comment.clone().unwrap_or_else(|| "-".to_owned()),
174            ]
175        })
176        .collect();
177
178    let mut out = crate::render::table::render(&columns, &rows, ctx);
179    let paint = ctx.painter();
180    let total = crate::api::duration::human(&total_duration(worklogs));
181    let _ = writeln!(
182        out,
183        "{}",
184        paint.paint(
185            &format!(
186                "shown {} of {} for {key} — {total} total",
187                rows.len(),
188                rows.len()
189            ),
190            Palette::label()
191        )
192    );
193    out
194}
195
196/// Worklog entries from across the organisation.
197///
198/// Different from [`worklogs`] in the one way that matters: the issue is a
199/// column, because here it is the only thing that says what the time was for.
200#[must_use]
201pub fn worklog_search(entries: &[Worklog], ctx: &Context) -> String {
202    let columns = [
203        Column::whole("ISSUE", 14, Palette::key()),
204        Column::whole("WHEN", 12, anstyle::Style::new()),
205        Column::whole("DURATION", 10, anstyle::Style::new()),
206        Column::new("WHO", 16, anstyle::Style::new()),
207        Column::new("COMMENT", 36, Palette::untrusted()),
208    ];
209
210    let rows: Vec<Vec<String>> = entries
211        .iter()
212        .map(|entry| {
213            vec![
214                entry.issue.clone().unwrap_or_else(|| "-".to_owned()),
215                entry.start.map_or_else(
216                    || "-".to_owned(),
217                    |start| start.to_string().chars().take(10).collect(),
218                ),
219                crate::api::duration::human(&entry.duration),
220                who(entry.author.as_ref()).to_owned(),
221                entry.comment.clone().unwrap_or_else(|| "-".to_owned()),
222            ]
223        })
224        .collect();
225
226    let mut out = crate::render::table::render(&columns, &rows, ctx);
227    let paint = ctx.painter();
228    let total = crate::api::duration::human(&total_duration(entries));
229    let _ = writeln!(
230        out,
231        "{}",
232        paint.paint(
233            &format!("shown {} of {} — {total} total", rows.len(), rows.len()),
234            Palette::label()
235        )
236    );
237    out
238}
239
240/// What changed on an issue, one line per field.
241///
242/// The unit is a field, not an event: "who set the status to Closed" is the
243/// question, and an event that touched three fields answers it three times over
244/// only if it is split. The event columns repeat as a result, which is the
245/// price of every line being readable on its own.
246///
247/// An event that changed nothing a caller can see — a transport detail, a
248/// re-index — still gets a line, with `-` for the field. Dropping it would make
249/// the history look like it has gaps.
250#[must_use]
251pub fn changelog(key: &str, changes: &[Change], ctx: &Context) -> String {
252    let columns = [
253        Column::whole("WHEN", 16, anstyle::Style::new()),
254        Column::new("WHO", 16, anstyle::Style::new()),
255        Column::new("FIELD", 16, Palette::key()),
256        Column::new("FROM", 20, Palette::label()),
257        Column::new("TO", 20, anstyle::Style::new()),
258    ];
259
260    let mut rows: Vec<Vec<String>> = Vec::new();
261    for change in changes {
262        let when = change.at.map_or_else(
263            || "-".to_owned(),
264            // Minutes, not seconds: two changes in the same second are told
265            // apart by their order, never by this column.
266            |at| at.to_string().chars().take(16).collect(),
267        );
268        let by = who(change.by.as_ref()).to_owned();
269
270        if change.fields.is_empty() {
271            rows.push(vec![
272                when,
273                by,
274                "-".to_owned(),
275                "-".to_owned(),
276                "-".to_owned(),
277            ]);
278            continue;
279        }
280        for field in &change.fields {
281            rows.push(vec![
282                when.clone(),
283                by.clone(),
284                field.field.clone(),
285                field.from.clone().unwrap_or_else(|| "-".to_owned()),
286                field.to.clone().unwrap_or_else(|| "-".to_owned()),
287            ]);
288        }
289    }
290
291    let mut out = crate::render::table::render(&columns, &rows, ctx);
292    let paint = ctx.painter();
293    let _ = writeln!(
294        out,
295        "{}",
296        paint.paint(
297            &format!(
298                "shown {} of {} for {key} — from {} {}",
299                rows.len(),
300                rows.len(),
301                changes.len(),
302                if changes.len() == 1 {
303                    "event"
304                } else {
305                    "events"
306                }
307            ),
308            Palette::label()
309        )
310    );
311    out
312}
313
314/// The sum of a worklog, in ISO 8601.
315///
316/// Days and weeks are left as they came: Tracker counts a working day as eight
317/// hours and a working week as five days, and quietly turning `P1D` into 24
318/// hours here would produce a total nobody's timesheet agrees with.
319fn total_duration(worklogs: &[Worklog]) -> String {
320    let (mut weeks, mut days, mut hours, mut minutes, mut seconds) = (0u64, 0u64, 0u64, 0u64, 0u64);
321
322    for entry in worklogs {
323        let Some(rest) = entry.duration.strip_prefix('P') else {
324            continue;
325        };
326        let mut number = String::new();
327        for character in rest.chars() {
328            if character.is_ascii_digit() {
329                number.push(character);
330                continue;
331            }
332            let Ok(value) = number.parse::<u64>() else {
333                number.clear();
334                continue;
335            };
336            match character {
337                'W' => weeks += value,
338                'D' => days += value,
339                'H' => hours += value,
340                'M' => minutes += value,
341                'S' => seconds += value,
342                _ => {}
343            }
344            number.clear();
345        }
346    }
347
348    minutes += seconds / 60;
349    seconds %= 60;
350    hours += minutes / 60;
351    minutes %= 60;
352
353    let mut out = String::from("P");
354    for (value, unit) in [(weeks, 'W'), (days, 'D')] {
355        if value > 0 {
356            let _ = write!(out, "{value}{unit}");
357        }
358    }
359    let mut time = String::new();
360    for (value, unit) in [(hours, 'H'), (minutes, 'M'), (seconds, 'S')] {
361        if value > 0 {
362            let _ = write!(time, "{value}{unit}");
363        }
364    }
365    if !time.is_empty() {
366        let _ = write!(out, "T{time}");
367    }
368    if out == "P" { "PT0M".to_owned() } else { out }
369}
370
371/// Render an issue's checklist.
372#[must_use]
373pub fn checklist(key: &str, items: &[ChecklistItem], ctx: &Context) -> String {
374    let mut out = String::with_capacity(items.len() * 64 + 32);
375    let paint = ctx.painter();
376
377    for item in items {
378        // The box is the state, and it is the first thing on the line so a
379        // column of them can be read at a glance.
380        let box_ = if item.checked { "[x]" } else { "[ ]" };
381        let assignee = match item.assignee.as_ref() {
382            Some(user) => format!(" @{}", who(Some(user))),
383            None => String::new(),
384        };
385        let deadline = match item.deadline.as_deref() {
386            Some(date) => format!(" due {date}"),
387            None => String::new(),
388        };
389        let _ = writeln!(
390            out,
391            "{} {} {}{}{}",
392            paint.paint(&item.id, Palette::key()),
393            box_,
394            paint.paint(&item.text, Palette::untrusted()),
395            assignee,
396            deadline
397        );
398    }
399
400    let done = items.iter().filter(|item| item.checked).count();
401    let _ = writeln!(
402        out,
403        "{}",
404        paint.paint(
405            &format!(
406                "shown {} of {} for {key} — {done} done",
407                items.len(),
408                items.len()
409            ),
410            Palette::label()
411        )
412    );
413    out
414}
415
416/// The pinned custom fields, then either all of the rest or a count of them.
417///
418/// An agent gets the count: the set differs per queue, most are empty, and
419/// dumping them makes the view unstable. A terminal gets all of them — the
420/// reason to hide them was never that they are uninteresting.
421fn custom_fields(out: &mut String, issue: &Issue, ctx: &Context) {
422    let paint = ctx.painter();
423    let label = |text: &str| paint.paint(text, Palette::label());
424
425    for key in &ctx.extra_fields {
426        if let Some(value) = issue.extra.get(key) {
427            let _ = writeln!(
428                out,
429                "{} {}",
430                label(&format!("{key}:")),
431                compact_value(value)
432            );
433        }
434    }
435
436    // The sort is load-bearing, not tidiness. `serde_json::Map` is a BTreeMap
437    // only until some dependency turns on `preserve_order`, at which point it
438    // becomes insertion-ordered and this would start varying with whatever order
439    // Tracker happened to serialise the payload in. Field order is a contract
440    // (ADR 3), so it is enforced here rather than inherited.
441    let mut unpinned: Vec<&String> = issue
442        .extra
443        .keys()
444        .filter(|key| !ctx.extra_fields.contains(key))
445        .collect();
446    unpinned.sort();
447
448    if unpinned.is_empty() {
449        return;
450    }
451
452    if ctx.is_human() {
453        for key in unpinned {
454            if let Some(value) = issue.extra.get(key) {
455                let _ = writeln!(
456                    out,
457                    "{} {}",
458                    label(&format!("{key}:")),
459                    compact_value(value)
460                );
461            }
462        }
463        return;
464    }
465
466    let shown: Vec<&str> = unpinned.iter().take(3).map(|k| k.as_str()).collect();
467    let rest = unpinned.len().saturating_sub(shown.len());
468    let suffix = if rest > 0 {
469        format!(", +{rest}")
470    } else {
471        String::new()
472    };
473    let _ = writeln!(
474        out,
475        "{} {} set ({}{suffix}) — see --fields",
476        label("custom:"),
477        unpinned.len(),
478        shown.join(", "),
479    );
480}
481
482/// What to call a link.
483///
484/// A recognised type gets our own wording; anything else keeps Tracker's, which
485/// is at least true. The fallback word "link" said nothing.
486fn relation_of(link: &crate::api::models::Link) -> String {
487    if link.kind == crate::api::models::LinkKind::Other
488        && let Some(relation) = &link.relation
489    {
490        return relation.clone();
491    }
492    link.kind.label().to_owned()
493}
494
495/// Links, one per line, always with their type.
496fn links_section(out: &mut String, issue: &Issue, ctx: &Context) {
497    let paint = ctx.painter();
498    let label = |text: &str| paint.paint(text, Palette::label());
499
500    if issue.links.is_empty() {
501        let _ = writeln!(out, "{} none", label("links:"));
502        return;
503    }
504
505    let _ = writeln!(out, "{}", label("links:"));
506    for link in &issue.links {
507        let _ = writeln!(
508            out,
509            "  {} {}{}",
510            label(&relation_of(link)),
511            paint.paint(&link.key, Palette::key()),
512            link.status
513                .as_ref()
514                .map_or_else(String::new, |status| format!(" [{status}]")),
515        );
516    }
517}
518
519/// The description, marked as somebody else's text and trimmed.
520fn description_section(out: &mut String, issue: &Issue, ctx: &Context) {
521    let Some(description) = issue.description.as_deref().filter(|d| !d.is_empty()) else {
522        return;
523    };
524
525    let (body, withheld) = untrusted::head(description, ctx.description_lines);
526    quoted_block(
527        out,
528        &format!("{}/description", issue.key),
529        &body,
530        withheld,
531        ctx,
532    );
533}
534
535/// A block of text somebody else wrote, in whichever form its reader can use.
536///
537/// The two audiences need different things from the same guarantee. An agent
538/// needs the boundary to survive being pasted into a prompt, so it gets the
539/// `<untrusted>` fence and the markdown source untouched. A person needs to
540/// read the thing: markdown is rendered, and the boundary becomes a margin bar
541/// on every line — a tag they would have to parse by eye is not a boundary.
542pub(crate) fn quoted_block(
543    out: &mut String,
544    source: &str,
545    body: &str,
546    withheld: usize,
547    ctx: &Context,
548) {
549    let paint = ctx.painter();
550    let label = |text: &str| paint.paint(text, Palette::label());
551
552    if ctx.is_human() {
553        let _ = writeln!(
554            out,
555            "{}",
556            label(&format!("--- {source} (written by Tracker users)"))
557        );
558        out.push_str(&crate::render::markdown::quoted(
559            body,
560            ctx.width,
561            paint,
562            &ctx.inline,
563        ));
564    } else {
565        let _ = writeln!(out, "{}", label("---"));
566        // The fence is dimmed and nothing more. Giving someone else's text the
567        // same styling as our own output would let it impersonate the tool.
568        let _ = writeln!(
569            out,
570            "{}",
571            paint.paint(&untrusted::fence(source, body), Palette::untrusted())
572        );
573    }
574
575    if withheld > 0 {
576        let _ = writeln!(
577            out,
578            "{}",
579            label(&format!("(+{withheld} more lines: --full)"))
580        );
581    }
582}
583
584/// Render the transitions available from the current status.
585#[must_use]
586pub fn transitions(key: &str, transitions: &[crate::api::Transition]) -> String {
587    let mut out = String::with_capacity(transitions.len() * 40 + 32);
588
589    for transition in transitions {
590        let _ = writeln!(
591            out,
592            "{:<20} {:<24} → {}",
593            transition.id,
594            transition.name,
595            transition.to.as_deref().unwrap_or("-"),
596        );
597    }
598
599    let _ = writeln!(
600        out,
601        "shown {} of {} for {key}",
602        transitions.len(),
603        transitions.len()
604    );
605    out
606}
607
608/// Render only the requested fields, on one line.
609///
610/// The cheapest rung of the ladder: a caller that needs a status does not need
611/// the other fourteen lines. Fields come back in the order they were asked for,
612/// and an unknown or unset field renders as `-` rather than vanishing — a
613/// missing column would silently shift everything after it.
614#[must_use]
615pub fn issue_selected(issue: &Issue, fields: &[String]) -> String {
616    let mut out = String::with_capacity(64 + fields.len() * 24);
617    out.push_str(&issue.key);
618
619    for field in fields {
620        let _ = write!(out, "  {field}={}", field_value(issue, field));
621    }
622
623    out.push('\n');
624    out
625}
626
627fn field_value(issue: &Issue, field: &str) -> String {
628    match field {
629        "key" => issue.key.clone(),
630        "summary" => issue.summary.clone(),
631        "status" => or_dash(issue.status.as_ref()).to_owned(),
632        "type" => or_dash(issue.issue_type.as_ref()).to_owned(),
633        "priority" => or_dash(issue.priority.as_ref()).to_owned(),
634        "queue" => or_dash(issue.queue.as_ref()).to_owned(),
635        "assignee" => who(issue.assignee.as_ref()).to_owned(),
636        "author" => who(issue.author.as_ref()).to_owned(),
637        "created" => issue
638            .created_at
639            .map_or_else(|| "-".to_owned(), |ts| ts.to_string()),
640        "updated" => issue
641            .updated_at
642            .map_or_else(|| "-".to_owned(), |ts| ts.to_string()),
643        "comments" => issue
644            .comment_count
645            .map_or_else(|| "-".to_owned(), |n| n.to_string()),
646        "links" => {
647            if issue.links.is_empty() {
648                "none".to_owned()
649            } else {
650                issue
651                    .links
652                    .iter()
653                    .map(|link| format!("{} {}", relation_of(link), link.key))
654                    .collect::<Vec<_>>()
655                    .join("; ")
656            }
657        }
658        custom => issue
659            .extra
660            .get(custom)
661            .map_or_else(|| "-".to_owned(), compact_value),
662    }
663}
664
665/// Render a page of issues.
666///
667/// The tally that follows is not decoration. Without it a caller that receives
668/// 25 rows cannot tell a complete answer from a truncated one, and "there are no
669/// open issues" is a worse failure than a few wasted tokens.
670#[must_use]
671pub fn issue_page(page: &Page<Issue>, ctx: &Context) -> String {
672    let columns = [
673        Column::whole("KEY", 12, Palette::key()),
674        Column::by_value("STATUS", 14, status_style),
675        Column::new("ASSIGNEE", 14, anstyle::Style::new()),
676        Column::new("SUMMARY", 60, anstyle::Style::new()),
677    ];
678    let rows: Vec<Vec<String>> = page
679        .items
680        .iter()
681        .map(|issue| {
682            vec![
683                issue.key.clone(),
684                or_dash(issue.status.as_ref()).to_owned(),
685                who(issue.assignee.as_ref()).to_owned(),
686                issue.summary.clone(),
687            ]
688        })
689        .collect();
690
691    let mut out = crate::render::table::render(&columns, &rows, ctx);
692    out.push_str(&crate::render::table::tally(
693        page.items.len(),
694        page.total,
695        page.has_more().then_some(page.page + 1),
696        ctx,
697    ));
698    out
699}
700
701/// Colour a status by what it means, not by its name: Tracker lets every queue
702/// invent its own, so the mapping is on the few well-known words and everything
703/// else stays unpainted rather than guessed at.
704fn status_style(status: &str) -> anstyle::Style {
705    let lower = status.to_ascii_lowercase();
706    if lower.contains("closed") || lower.contains("resolved") || lower.contains("done") {
707        Palette::ok()
708    } else if lower.contains("progress") || lower.contains("review") || lower.contains("test") {
709        Palette::warn()
710    } else {
711        anstyle::Style::new()
712    }
713}
714
715fn status_painted(status: Option<&str>, ctx: &Context) -> String {
716    let Some(status) = status else {
717        return "-".to_owned();
718    };
719    ctx.painter().paint(status, status_style(status))
720}
721
722/// Critical and blocker are worth noticing; the rest are not worth a colour.
723fn priority_painted(priority: Option<&str>, ctx: &Context) -> String {
724    let paint = ctx.painter();
725    let Some(priority) = priority else {
726        return "-".to_owned();
727    };
728
729    let lower = priority.to_ascii_lowercase();
730    if lower.contains("critical") || lower.contains("blocker") {
731        paint.paint(priority, Palette::bad())
732    } else {
733        priority.to_owned()
734    }
735}
736
737fn compact_value(value: &serde_json::Value) -> String {
738    match value {
739        serde_json::Value::String(s) => s.clone(),
740        serde_json::Value::Array(items) => items
741            .iter()
742            .map(compact_value)
743            .collect::<Vec<_>>()
744            .join(", "),
745        serde_json::Value::Object(fields) => fields
746            .get("display")
747            .or_else(|| fields.get("name"))
748            .or_else(|| fields.get("key"))
749            .or_else(|| fields.get("id"))
750            .map_or_else(|| value.to_string(), compact_value),
751        other => other.to_string(),
752    }
753}
754
755#[cfg(test)]
756#[allow(clippy::expect_used)]
757mod tests {
758    use super::*;
759    use crate::api::models::{Link, LinkKind};
760    use crate::render::{Audience, Format};
761
762    fn ctx() -> Context {
763        Context {
764            format: Format::Text,
765            audience: Audience::Machine,
766            description_lines: Some(2),
767            extra_fields: vec!["storyPoints".to_owned()],
768            width: 80,
769            images: false,
770            inline: crate::render::image::Inline::default(),
771        }
772    }
773
774    fn sample() -> Issue {
775        let mut extra = serde_json::Map::new();
776        extra.insert("storyPoints".to_owned(), serde_json::json!(3));
777        extra.insert("sprint".to_owned(), serde_json::json!("S-12"));
778        extra.insert("component".to_owned(), serde_json::json!("api"));
779        extra.insert("team".to_owned(), serde_json::json!("core"));
780        extra.insert("risk".to_owned(), serde_json::json!("low"));
781
782        Issue {
783            key: "PROJ-1".to_owned(),
784            summary: "Attachments are lost on move".to_owned(),
785            status: Some("In Progress".to_owned()),
786            issue_type: Some("Bug".to_owned()),
787            priority: Some("Critical".to_owned()),
788            queue: Some("PROJ".to_owned()),
789            assignee: Some(User {
790                id: "1".to_owned(),
791                login: Some("ilubenets".to_owned()),
792                display: None,
793            }),
794            author: Some(User {
795                id: "2".to_owned(),
796                login: Some("reporter".to_owned()),
797                display: None,
798            }),
799            created_at: None,
800            updated_at: Some(
801                "2026-08-27T10:00:00Z"
802                    .parse::<jiff::Timestamp>()
803                    .expect("timestamp"),
804            ),
805            description: Some("line one\nline two\nline three\nline four".to_owned()),
806            links: vec![
807                Link {
808                    kind: LinkKind::IsBlockedBy,
809                    relation: None,
810                    key: "PROJ-3".to_owned(),
811                    summary: None,
812                    status: Some("Open".to_owned()),
813                },
814                Link {
815                    kind: LinkKind::Parent,
816                    relation: None,
817                    key: "PROJ-9".to_owned(),
818                    summary: None,
819                    status: None,
820                },
821            ],
822            comment_count: Some(3),
823            extra,
824        }
825    }
826
827    /// The compact view is the contract with every caller: a reordered or
828    /// silently widened field list breaks agents' prompt caches and users'
829    /// scripts alike, so it is pinned here.
830    #[test]
831    fn issue_compact_view_is_stable() {
832        insta::assert_snapshot!(issue(&sample(), &ctx()));
833    }
834
835    fn human_ctx() -> Context {
836        Context {
837            audience: Audience::Human,
838            ..ctx()
839        }
840    }
841
842    /// The terminal form is pinned too, escape codes and all: colour is part of
843    /// what people see, and a change to it should be as visible in review as a
844    /// change to the words.
845    #[test]
846    fn issue_terminal_view_is_stable() {
847        insta::assert_snapshot!(issue(&sample(), &human_ctx()));
848    }
849
850    /// The rule that makes styling safe: same words, same order, same data —
851    /// only escape codes differ. Compared at the same detail level, since a
852    /// terminal is also given more of the issue.
853    #[test]
854    fn styling_changes_nothing_but_the_escape_codes() {
855        let coloured_machine = Context {
856            audience: Audience::Human,
857            ..ctx()
858        };
859        let plain = issue(
860            &sample(),
861            &Context {
862                audience: Audience::Machine,
863                ..coloured_machine.clone()
864            },
865        );
866        let coloured = issue(&sample(), &coloured_machine);
867
868        // Human output lists custom fields instead of counting them, so compare
869        // the shared prefix: everything up to that line.
870        let cut = |text: &str| {
871            text.lines()
872                .take_while(|line| !line.contains("custom:") && !line.starts_with("component:"))
873                .collect::<Vec<_>>()
874                .join("\n")
875        };
876        assert_eq!(cut(&strip_ansi(&coloured)), cut(&plain));
877    }
878
879    /// A person reading their own terminal is not paying for context, so the
880    /// description is not cut short there.
881    #[test]
882    fn a_terminal_gets_the_whole_description_and_every_custom_field() {
883        let human = Context {
884            audience: Audience::Human,
885            description_lines: None,
886            ..ctx()
887        };
888        let rendered = strip_ansi(&issue(&sample(), &human));
889
890        assert!(rendered.contains("line four"));
891        assert!(!rendered.contains("more lines: --full"));
892        assert!(rendered.contains("component: api"));
893        assert!(rendered.contains("team: core"));
894        assert!(!rendered.contains("custom: "));
895    }
896
897    fn strip_ansi(text: &str) -> String {
898        let mut out = String::with_capacity(text.len());
899        let mut chars = text.chars();
900        while let Some(c) = chars.next() {
901            if c != '\u{1b}' {
902                out.push(c);
903                continue;
904            }
905            // Skip until the terminating letter of the CSI sequence.
906            for c in chars.by_ref() {
907                if c.is_ascii_alphabetic() {
908                    break;
909                }
910            }
911        }
912        out
913    }
914
915    /// A reference field is one readable word wrapped in sixty of plumbing.
916    /// Both audiences want the word; the plumbing is still in `--format json`.
917    #[test]
918    fn a_reference_field_renders_as_its_display_name() {
919        let value = serde_json::json!([
920            {"display": "Platform: backend", "id": "6", "self": "https://api/6"},
921            {"display": "Platform: frontend", "id": "7", "self": "https://api/7"},
922        ]);
923        assert_eq!(
924            compact_value(&value),
925            "Platform: backend, Platform: frontend"
926        );
927    }
928
929    /// An object with nothing to display is printed rather than dropped: a field
930    /// that silently renders as nothing is worse than an ugly one.
931    #[test]
932    fn an_unrecognised_object_still_shows_its_contents() {
933        let value = serde_json::json!({"weird": 1});
934        assert_eq!(compact_value(&value), "{\"weird\":1}");
935    }
936
937    /// The fence is what an agent uses to tell content from instruction, and it
938    /// has to survive being pasted into a prompt. Rendering is for the terminal.
939    #[test]
940    fn machine_output_keeps_the_fence_and_the_markdown_source() {
941        let mut issue_md = sample();
942        issue_md.description = Some("# Title\n\n**loud**".to_owned());
943        let rendered = issue(
944            &issue_md,
945            &Context {
946                description_lines: None,
947                ..ctx()
948            },
949        );
950        assert!(rendered.contains("<untrusted src=\"PROJ-1/description\""));
951        assert!(rendered.contains("# Title"));
952        assert!(rendered.contains("**loud**"));
953    }
954
955    /// A person gets the text, not its syntax — but never without the margin
956    /// that says who wrote it.
957    #[test]
958    fn a_terminal_gets_rendered_markdown_behind_a_margin() {
959        let mut issue_md = sample();
960        issue_md.description = Some("# Title\n\n**loud**".to_owned());
961        let rendered = issue(
962            &issue_md,
963            &Context {
964                audience: Audience::Human,
965                description_lines: None,
966                ..ctx()
967            },
968        );
969        let plain = strip_ansi(&rendered);
970
971        assert!(!plain.contains("<untrusted"));
972        assert!(!plain.contains("**"));
973        assert!(plain.contains("(written by Tracker users)"));
974        assert!(plain.contains("Title"));
975        for line in plain
976            .lines()
977            .skip_while(|l| !l.contains("written by"))
978            .skip(1)
979        {
980            assert!(line.starts_with('\u{258f}'), "unmarked line: {line}");
981        }
982    }
983
984    #[test]
985    fn description_is_fenced_and_trimmed() {
986        let rendered = issue(&sample(), &ctx());
987        assert!(rendered.contains("<untrusted src=\"PROJ-1/description\""));
988        assert!(rendered.contains("(+2 more lines: --full)"));
989        assert!(!rendered.contains("line three"));
990    }
991
992    #[test]
993    fn links_always_carry_their_type() {
994        let rendered = issue(&sample(), &ctx());
995        assert!(rendered.contains("is blocked by PROJ-3 [Open]"));
996        assert!(rendered.contains("parent PROJ-9"));
997    }
998
999    #[test]
1000    fn issue_without_links_says_so_rather_than_omitting_the_line() {
1001        let mut issue_without = sample();
1002        issue_without.links.clear();
1003        assert!(issue(&issue_without, &ctx()).contains("links: none"));
1004    }
1005
1006    /// A truncated page that does not say it is truncated is worse than a
1007    /// verbose one: the caller concludes the result set is complete.
1008    #[test]
1009    fn page_reports_totals_and_the_next_page() {
1010        let page = Page {
1011            items: vec![sample()],
1012            page: 1,
1013            per_page: 1,
1014            total: Some(340),
1015        };
1016        insta::assert_snapshot!(issue_page(&page, &ctx()));
1017    }
1018
1019    /// Enabling an unrelated feature must not reorder the view. `serde_json`'s
1020    /// map type changes behaviour when any dependency turns on `preserve_order`,
1021    /// which is exactly the kind of silent drift a fixed field order forbids.
1022    #[test]
1023    fn custom_field_summary_does_not_depend_on_payload_order() {
1024        let mut shuffled = sample();
1025        let entries: Vec<(String, serde_json::Value)> = vec![
1026            ("team".to_owned(), serde_json::json!("core")),
1027            ("component".to_owned(), serde_json::json!("api")),
1028            ("risk".to_owned(), serde_json::json!("low")),
1029            ("sprint".to_owned(), serde_json::json!("S-12")),
1030            ("storyPoints".to_owned(), serde_json::json!(3)),
1031        ];
1032        shuffled.extra = entries.into_iter().collect();
1033
1034        assert_eq!(issue(&sample(), &ctx()), issue(&shuffled, &ctx()));
1035    }
1036
1037    #[test]
1038    fn selected_fields_keep_the_order_they_were_asked_for() {
1039        let fields = vec![
1040            "status".to_owned(),
1041            "storyPoints".to_owned(),
1042            "assignee".to_owned(),
1043        ];
1044        assert_eq!(
1045            issue_selected(&sample(), &fields),
1046            "PROJ-1  status=In Progress  storyPoints=3  assignee=ilubenets\n"
1047        );
1048    }
1049
1050    /// A field that is absent must still occupy its place: silently dropping it
1051    /// shifts every column after it for anything parsing the line.
1052    #[test]
1053    fn an_unknown_field_renders_as_a_dash() {
1054        let fields = vec!["nonsense".to_owned(), "status".to_owned()];
1055        assert_eq!(
1056            issue_selected(&sample(), &fields),
1057            "PROJ-1  nonsense=-  status=In Progress\n"
1058        );
1059    }
1060
1061    #[test]
1062    fn complete_page_does_not_offer_a_next_one() {
1063        let page = Page {
1064            items: vec![sample()],
1065            page: 1,
1066            per_page: 25,
1067            total: Some(1),
1068        };
1069        let rendered = issue_page(&page, &ctx());
1070        assert!(rendered.contains("shown 1 of 1"));
1071        assert!(!rendered.contains("next:"));
1072    }
1073}