Skip to main content

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;
29pub mod wiki;
30
31use std::str::FromStr;
32
33use serde::{Deserialize, Serialize};
34
35/// Output format.
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "kebab-case")]
38pub enum Format {
39    /// Compact key/value text. The default, and the only format tuned for tokens.
40    #[default]
41    Text,
42    /// Our normalised schema, stable across Tracker API changes.
43    Json,
44    /// The upstream payload, verbatim. Escape hatch, never the default.
45    JsonRaw,
46    /// Token-Oriented Object Notation. Always available; it only pays off on
47    /// uniform lists, which is why it is not the default.
48    Toon,
49}
50
51impl FromStr for Format {
52    type Err = String;
53
54    fn from_str(value: &str) -> Result<Self, Self::Err> {
55        match value {
56            "text" => Ok(Self::Text),
57            "json" => Ok(Self::Json),
58            "json-raw" => Ok(Self::JsonRaw),
59            "toon" => Ok(Self::Toon),
60            other => Err(format!(
61                "unknown format `{other}` (expected text, json, json-raw or toon)"
62            )),
63        }
64    }
65}
66
67/// How the output is meant to be consumed.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Audience {
70    /// stdout is a terminal: colour and tables are welcome.
71    Human,
72    /// stdout is a pipe: no colour, no box drawing, stable line shapes.
73    Machine,
74}
75
76impl Audience {
77    /// Decide from the environment. Explicit `--format` still overrides the
78    /// format; this only decides decoration.
79    #[must_use]
80    pub fn detect() -> Self {
81        use std::io::IsTerminal;
82        if std::io::stdout().is_terminal() {
83            Self::Human
84        } else {
85            Self::Machine
86        }
87    }
88}
89
90/// Rendering context assembled once per command.
91#[derive(Debug, Clone)]
92pub struct Context {
93    pub format: Format,
94    pub audience: Audience,
95    /// Description lines before the `--full` hint; `None` means no limit.
96    pub description_lines: Option<usize>,
97    /// Custom field keys to surface, in this exact order.
98    pub extra_fields: Vec<String>,
99    /// Columns available for wrapped prose. Fixed for machine output, so a pipe
100    /// gets the same bytes whatever the window happens to be.
101    pub width: usize,
102    /// Whether image attachments may be drawn inline. Says nothing about whether
103    /// the terminal can: that is [`image::protocol`].
104    pub images: bool,
105    /// Pictures to put where the text references them, keyed by URL. Filled in
106    /// by the command, once it knows there is a terminal to draw on.
107    pub inline: image::Inline,
108}
109
110impl Context {
111    #[must_use]
112    pub fn is_human(&self) -> bool {
113        self.audience == Audience::Human
114    }
115
116    /// The painter for this context.
117    ///
118    /// Machine output is never styled — not stripped after the fact, never
119    /// produced — so what a snapshot test pins is exactly what a pipe receives.
120    #[must_use]
121    pub fn painter(&self) -> style::Painter {
122        style::Painter::for_stream(self.is_human())
123    }
124}
125
126/// Serialise a value in whichever machine format was asked for.
127///
128/// `text` is not handled here: it is per-entity and lives in [`text`].
129pub fn machine<T: serde::Serialize>(value: &T, format: Format) -> Result<String, RenderError> {
130    match format {
131        Format::Json | Format::JsonRaw => {
132            Ok(serde_json::to_string_pretty(value).map(|json| json + "\n")?)
133        }
134        Format::Toon => Ok(toon_format::encode_default(value)? + "\n"),
135        Format::Text => Err(RenderError::NotMachineReadable),
136    }
137}
138
139#[derive(Debug, thiserror::Error)]
140pub enum RenderError {
141    #[error("could not serialise the result")]
142    Serialise(#[from] serde_json::Error),
143    #[error("text output is rendered per entity, not generically")]
144    NotMachineReadable,
145    #[error("could not encode as TOON")]
146    Toon(#[from] toon_format::ToonError),
147}