hurl/util/term.rs
1/*
2 * Hurl (https://hurl.dev)
3 * Copyright (C) 2025 Orange
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 * http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 *
17 */
18//! Wrapper on standard output/error.
19use std::io;
20#[cfg(target_family = "windows")]
21use std::io::IsTerminal;
22use std::io::Write;
23
24/// The way to write on standard output and error: either immediate like `println!` macro,
25/// or buffered in an internal buffer.
26#[derive(Copy, Clone, Debug, Eq, PartialEq)]
27pub enum WriteMode {
28 /// Messages are printed immediately.
29 Immediate,
30 /// Messages are saved to an internal buffer, and can be retrieved with [`Stdout::buffer`] /
31 /// [`Stderr::buffer`].
32 Buffered,
33}
34
35/// Indirection for standard output.
36///
37/// Depending on `mode`, bytes are immediately printed to standard output, or buffered in an
38/// internal buffer.
39pub struct Stdout {
40 /// Write mode of the standard output: immediate or saved to a buffer.
41 mode: WriteMode,
42 /// Internal buffer, filled when `mode` is [`WriteMode::Buffered`]
43 buffer: Vec<u8>,
44}
45
46impl Stdout {
47 /// Creates a new standard output, buffered or immediate depending on `mode`.
48 pub fn new(mode: WriteMode) -> Self {
49 Stdout {
50 mode,
51 buffer: Vec::new(),
52 }
53 }
54
55 /// Attempts to write an entire buffer into standard output.
56 pub fn write_all(&mut self, buf: &[u8]) -> Result<(), io::Error> {
57 match self.mode {
58 WriteMode::Immediate => write_stdout(buf),
59 WriteMode::Buffered => self.buffer.write_all(buf),
60 }
61 }
62
63 /// Returns the buffered standard output.
64 pub fn buffer(&self) -> &[u8] {
65 &self.buffer
66 }
67}
68
69#[cfg(target_family = "unix")]
70fn write_stdout(buf: &[u8]) -> Result<(), io::Error> {
71 let mut handle = io::stdout().lock();
72 handle.write_all(buf)?;
73 Ok(())
74}
75
76#[cfg(target_family = "windows")]
77fn write_stdout(buf: &[u8]) -> Result<(), io::Error> {
78 // From <https://doc.rust-lang.org/std/io/struct.Stdout.html>:
79 // > When operating in a console, the Windows implementation of this stream does not support
80 // > non-UTF-8 byte sequences. Attempting to write bytes that are not valid UTF-8 will return
81 // > an error.
82 // As a workaround to prevent error, we convert the buffer to an UTF-8 string (with potential
83 // bytes losses) before writing to the standard output of the Windows console.
84 if io::stdout().is_terminal() {
85 println!("{}", String::from_utf8_lossy(buf));
86 } else {
87 let mut handle = io::stdout().lock();
88 handle.write_all(buf)?;
89 }
90 Ok(())
91}
92
93/// Indirection for standard error.
94///
95/// Depending on `mode`, messages are immediately printed to standard error, or buffered in an
96/// internal buffer.
97///
98/// An optional `progress` string can be used to report temporary progress indication to the user.
99/// It's always printed as the last lines of the standard error. When the standard error is created
100/// with [`WriteMode::Buffered`], the progress is not saved in the internal buffer.
101#[derive(Clone, Debug)]
102pub struct Stderr {
103 /// Write mode of the standard error: immediate or saved to a buffer.
104 mode: WriteMode,
105 /// Internal buffer, filled when `mode` is [`WriteMode::Buffered`]
106 buffer: String,
107 /// Progress bar: when not empty, it is always displayed at the end of the terminal.
108 progress_bar: String,
109}
110
111impl Stderr {
112 /// Creates a new standard error, buffered or immediate depending on `mode`.
113 pub fn new(mode: WriteMode) -> Self {
114 Stderr {
115 mode,
116 buffer: String::new(),
117 progress_bar: String::new(),
118 }
119 }
120
121 /// Returns the [`WriteMode`] of this logger.
122 pub fn mode(&self) -> WriteMode {
123 self.mode
124 }
125
126 /// Prints to the standard error, with a newline.
127 pub fn eprintln(&mut self, message: &str) {
128 match self.mode {
129 WriteMode::Immediate => {
130 let has_progress = !self.progress_bar.is_empty();
131 if has_progress {
132 self.rewind_cursor();
133 }
134 eprintln!("{message}");
135 if has_progress {
136 eprint!("{}", self.progress_bar);
137 }
138 }
139 WriteMode::Buffered => {
140 self.buffer.push_str(message);
141 self.buffer.push('\n');
142 }
143 }
144 }
145
146 /// Prints to the standard error.
147 pub fn eprint(&mut self, message: &str) {
148 match self.mode {
149 WriteMode::Immediate => {
150 let has_progress = !self.progress_bar.is_empty();
151 if has_progress {
152 self.rewind_cursor();
153 }
154 eprint!("{message}");
155 if has_progress {
156 eprint!("{}", self.progress_bar);
157 }
158 }
159 WriteMode::Buffered => {
160 self.buffer.push_str(message);
161 }
162 }
163 }
164
165 /// Sets the progress bar (only in [`WriteMode::Immediate`] mode).
166 pub fn set_progress_bar(&mut self, progress: &str) {
167 match self.mode {
168 WriteMode::Immediate => {
169 self.progress_bar = progress.to_string();
170 eprint!("{}", self.progress_bar);
171 }
172 WriteMode::Buffered => {}
173 }
174 }
175
176 /// Clears the progress string (only in [`WriteMode::Immediate`] mode).
177 pub fn clear_progress_bar(&mut self) {
178 self.rewind_cursor();
179 self.progress_bar.clear();
180 }
181
182 /// Returns the buffered standard error.
183 pub fn buffer(&self) -> &str {
184 &self.buffer
185 }
186
187 /// Set the buffered standard error.
188 pub fn set_buffer(&mut self, buffer: String) {
189 self.buffer = buffer;
190 }
191
192 /// Clears any progress and reset cursor terminal to the position of the last "real" message
193 /// (only in [`WriteMode::Immediate`] mode).
194 fn rewind_cursor(&self) {
195 if self.progress_bar.is_empty() {
196 return;
197 }
198 match self.mode {
199 WriteMode::Immediate => {
200 // We count the number of new lines \n. We can't use the `String::lines()` because
201 // it counts a line for a single char and we don't want to go up for a single char.
202 let lines = self.progress_bar.chars().filter(|c| *c == '\n').count();
203
204 // We used the following ANSI codes:
205 // - K: "EL - Erase in Line" sequence. It clears from the cursor to the end of line.
206 // - 1A: "Cursor Up". Up to one line
207 // <https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_sequences>
208 if lines > 0 {
209 (0..lines).for_each(|_| eprint!("\x1B[1A\x1B[K"));
210 } else {
211 eprint!("\x1B[K");
212 }
213 }
214 WriteMode::Buffered => {}
215 }
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use crate::util::term::{Stderr, Stdout, WriteMode};
222
223 #[test]
224 fn buffered_stdout() {
225 let mut stdout = Stdout::new(WriteMode::Buffered);
226 stdout.write_all(b"Hello").unwrap();
227 stdout.write_all(b" ").unwrap();
228 stdout.write_all(b"World!").unwrap();
229 assert_eq!(stdout.buffer(), b"Hello World!");
230 }
231
232 #[test]
233 fn buffered_stderr() {
234 let mut stderr = Stderr::new(WriteMode::Buffered);
235 stderr.eprintln("toto");
236 stderr.set_progress_bar("some progress...\r");
237 stderr.eprintln("tutu");
238
239 assert_eq!(stderr.buffer(), "toto\ntutu\n");
240 }
241}