ytcli/render/mod.rs
1//! Output rendering — the detail ladder.
2//!
3//! The shape of what we print *is* the product (`docs/adr/0003-output-ladder.md`).
4//! Two rules hold everywhere in this module:
5//!
6//! 1. **Field order is fixed.** A view that reorders itself between calls breaks
7//! an agent's prompt cache and any script that reads the output.
8//! 2. **Free text coming from Tracker is fenced.** Summaries, descriptions and
9//! comments were written by other people and may contain instructions aimed
10//! at whatever reads them; they are data, and they are labelled as such.
11//!
12//! Snapshot tests cover every renderer, so changing a default shape shows up as
13//! a diff in review rather than as a surprise in someone's pipeline.
14
15pub mod bar;
16pub mod board;
17pub mod bulk;
18pub mod dict;
19pub mod entity;
20pub mod image;
21pub mod markdown;
22pub mod progress;
23pub mod queue;
24pub mod style;
25pub mod table;
26pub mod text;
27pub mod untrusted;
28pub mod user;
29
30use std::str::FromStr;
31
32use serde::{Deserialize, Serialize};
33
34/// Output format.
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "kebab-case")]
37pub enum Format {
38 /// Compact key/value text. The default, and the only format tuned for tokens.
39 #[default]
40 Text,
41 /// Our normalised schema, stable across Tracker API changes.
42 Json,
43 /// The upstream payload, verbatim. Escape hatch, never the default.
44 JsonRaw,
45 /// Token-Oriented Object Notation. Always available; it only pays off on
46 /// uniform lists, which is why it is not the default.
47 Toon,
48}
49
50impl FromStr for Format {
51 type Err = String;
52
53 fn from_str(value: &str) -> Result<Self, Self::Err> {
54 match value {
55 "text" => Ok(Self::Text),
56 "json" => Ok(Self::Json),
57 "json-raw" => Ok(Self::JsonRaw),
58 "toon" => Ok(Self::Toon),
59 other => Err(format!(
60 "unknown format `{other}` (expected text, json, json-raw or toon)"
61 )),
62 }
63 }
64}
65
66/// How the output is meant to be consumed.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum Audience {
69 /// stdout is a terminal: colour and tables are welcome.
70 Human,
71 /// stdout is a pipe: no colour, no box drawing, stable line shapes.
72 Machine,
73}
74
75impl Audience {
76 /// Decide from the environment. Explicit `--format` still overrides the
77 /// format; this only decides decoration.
78 #[must_use]
79 pub fn detect() -> Self {
80 use std::io::IsTerminal;
81 if std::io::stdout().is_terminal() {
82 Self::Human
83 } else {
84 Self::Machine
85 }
86 }
87}
88
89/// Rendering context assembled once per command.
90#[derive(Debug, Clone)]
91pub struct Context {
92 pub format: Format,
93 pub audience: Audience,
94 /// Description lines before the `--full` hint; `None` means no limit.
95 pub description_lines: Option<usize>,
96 /// Custom field keys to surface, in this exact order.
97 pub extra_fields: Vec<String>,
98 /// Columns available for wrapped prose. Fixed for machine output, so a pipe
99 /// gets the same bytes whatever the window happens to be.
100 pub width: usize,
101 /// Whether image attachments may be drawn inline. Says nothing about whether
102 /// the terminal can: that is [`image::protocol`].
103 pub images: bool,
104 /// Pictures to put where the text references them, keyed by URL. Filled in
105 /// by the command, once it knows there is a terminal to draw on.
106 pub inline: image::Inline,
107}
108
109impl Context {
110 #[must_use]
111 pub fn is_human(&self) -> bool {
112 self.audience == Audience::Human
113 }
114
115 /// The painter for this context.
116 ///
117 /// Machine output is never styled — not stripped after the fact, never
118 /// produced — so what a snapshot test pins is exactly what a pipe receives.
119 #[must_use]
120 pub fn painter(&self) -> style::Painter {
121 style::Painter::for_stream(self.is_human())
122 }
123}
124
125/// Serialise a value in whichever machine format was asked for.
126///
127/// `text` is not handled here: it is per-entity and lives in [`text`].
128pub fn machine<T: serde::Serialize>(value: &T, format: Format) -> Result<String, RenderError> {
129 match format {
130 Format::Json | Format::JsonRaw => {
131 Ok(serde_json::to_string_pretty(value).map(|json| json + "\n")?)
132 }
133 Format::Toon => Ok(toon_format::encode_default(value)? + "\n"),
134 Format::Text => Err(RenderError::NotMachineReadable),
135 }
136}
137
138#[derive(Debug, thiserror::Error)]
139pub enum RenderError {
140 #[error("could not serialise the result")]
141 Serialise(#[from] serde_json::Error),
142 #[error("text output is rendered per entity, not generically")]
143 NotMachineReadable,
144 #[error("could not encode as TOON")]
145 Toon(#[from] toon_format::ToonError),
146}