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;
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}