Skip to main content

urushi/
output.rs

1//! Convenience output for the process standard streams.
2
3use std::io::{self, Write};
4
5use urushi_terminal::{ColorLevel, TerminalDetection};
6
7use crate::{Available, RenderSettings, StyledText, View, render, render_text, resolve};
8
9/// Resolves `view` for stdout and writes it without a trailing newline.
10pub fn print_view(view: &View) -> io::Result<()> {
11    let stdout = io::stdout();
12    let detection = urushi_terminal::detect(&stdout)?;
13    write_view_detected(stdout.lock(), view, detection, false, no_color())
14}
15
16/// Resolves `view` for stdout and writes it with a trailing newline.
17pub fn println_view(view: &View) -> io::Result<()> {
18    let stdout = io::stdout();
19    let detection = urushi_terminal::detect(&stdout)?;
20    write_view_detected(stdout.lock(), view, detection, true, no_color())
21}
22
23/// Resolves `view` for stderr and writes it without a trailing newline.
24pub fn eprint_view(view: &View) -> io::Result<()> {
25    let stderr = io::stderr();
26    let detection = urushi_terminal::detect(&stderr)?;
27    write_view_detected(stderr.lock(), view, detection, false, no_color())
28}
29
30/// Resolves `view` for stderr and writes it with a trailing newline.
31pub fn eprintln_view(view: &View) -> io::Result<()> {
32    let stderr = io::stderr();
33    let detection = urushi_terminal::detect(&stderr)?;
34    write_view_detected(stderr.lock(), view, detection, true, no_color())
35}
36
37/// Writes styled text to stdout without resolving layout or adding a newline.
38pub fn print(text: &StyledText) -> io::Result<()> {
39    let stdout = io::stdout();
40    let detection = urushi_terminal::detect(&stdout)?;
41    write_text_detected(stdout.lock(), text, detection, false, no_color())
42}
43
44/// Writes styled text to stdout without resolving layout, then adds a newline.
45pub fn println(text: &StyledText) -> io::Result<()> {
46    let stdout = io::stdout();
47    let detection = urushi_terminal::detect(&stdout)?;
48    write_text_detected(stdout.lock(), text, detection, true, no_color())
49}
50
51/// Writes styled text to stderr without resolving layout or adding a newline.
52pub fn eprint(text: &StyledText) -> io::Result<()> {
53    let stderr = io::stderr();
54    let detection = urushi_terminal::detect(&stderr)?;
55    write_text_detected(stderr.lock(), text, detection, false, no_color())
56}
57
58/// Writes styled text to stderr without resolving layout, then adds a newline.
59pub fn eprintln(text: &StyledText) -> io::Result<()> {
60    let stderr = io::stderr();
61    let detection = urushi_terminal::detect(&stderr)?;
62    write_text_detected(stderr.lock(), text, detection, true, no_color())
63}
64
65fn no_color() -> bool {
66    std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty())
67}
68
69fn write_view_detected(
70    writer: impl Write,
71    view: &View,
72    detection: TerminalDetection,
73    newline: bool,
74    no_color: bool,
75) -> io::Result<()> {
76    let (available, settings) = output_configuration(detection, no_color);
77    write_configured(writer, view, available, settings, newline)
78}
79
80fn write_text_detected(
81    mut writer: impl Write,
82    text: &StyledText,
83    detection: TerminalDetection,
84    newline: bool,
85    no_color: bool,
86) -> io::Result<()> {
87    let (_, settings) = output_configuration(detection, no_color);
88    writer.write_all(render_text(text, &settings).as_bytes())?;
89    if newline {
90        writer.write_all(b"\n")?;
91    }
92    Ok(())
93}
94
95fn write_configured(
96    mut writer: impl Write,
97    view: &View,
98    available: Available,
99    settings: RenderSettings,
100    newline: bool,
101) -> io::Result<()> {
102    let resolved = resolve(view, available).map_err(io::Error::other)?;
103    writer.write_all(render(&resolved, &settings).as_bytes())?;
104    if newline {
105        writer.write_all(b"\n")?;
106    }
107    Ok(())
108}
109
110fn output_configuration(
111    detection: TerminalDetection,
112    no_color: bool,
113) -> (Available, RenderSettings) {
114    match detection {
115        TerminalDetection::Terminal(info) => {
116            let settings = RenderSettings::from(info.capabilities());
117            let settings = apply_no_color(settings, no_color);
118            (Available::columns(info.size().columns()), settings)
119        }
120        TerminalDetection::NonTerminal => (Available::NONE, RenderSettings::default()),
121    }
122}
123
124fn apply_no_color(settings: RenderSettings, no_color: bool) -> RenderSettings {
125    if no_color {
126        settings.color_level(ColorLevel::None)
127    } else {
128        settings
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    use crate::{Color, TextAttribute, TextStyle};
137
138    #[test]
139    fn terminal_output_uses_its_width_and_capabilities() {
140        let mut output = Vec::new();
141        let view = View::text(
142            "abcdef",
143            TextStyle::new().foreground(Color::RED).bold().italic(),
144        );
145
146        write_configured(
147            &mut output,
148            &view,
149            Available::columns(3),
150            RenderSettings::default()
151                .color_level(ColorLevel::Ansi16)
152                .attributes(TextAttribute::Bold.into()),
153            false,
154        )
155        .unwrap();
156
157        assert_eq!(
158            String::from_utf8(output).unwrap(),
159            "\x1b[1;31mabc\x1b[0m\n\x1b[1;31mdef\x1b[0m"
160        );
161    }
162
163    #[test]
164    fn no_color_narrows_settings_without_disabling_other_features() {
165        let settings = RenderSettings::default()
166            .color_level(ColorLevel::Ansi16)
167            .attributes(TextAttribute::Bold.into());
168
169        let narrowed = apply_no_color(settings, true);
170
171        assert_eq!(narrowed.get_color_level(), ColorLevel::None);
172        assert_eq!(narrowed.get_attributes(), TextAttribute::Bold.into());
173        assert_eq!(apply_no_color(settings, false), settings);
174    }
175
176    #[test]
177    fn redirected_output_is_an_unbounded_plain_dump() {
178        let mut output = Vec::new();
179        let view = View::text("abcdef", TextStyle::new().foreground(Color::RED));
180
181        write_view_detected(
182            &mut output,
183            &view,
184            TerminalDetection::NonTerminal,
185            true,
186            false,
187        )
188        .unwrap();
189
190        assert_eq!(String::from_utf8(output).unwrap(), "abcdef\n");
191    }
192
193    #[test]
194    fn redirected_direct_text_preserves_tabs_without_layout() {
195        let mut output = Vec::new();
196        let text = StyledText::new("name\tvalue", TextStyle::new().bold());
197
198        write_text_detected(
199            &mut output,
200            &text,
201            TerminalDetection::NonTerminal,
202            true,
203            false,
204        )
205        .unwrap();
206
207        assert_eq!(String::from_utf8(output).unwrap(), "name\tvalue\n");
208    }
209}