bitbelay_report/lib.rs
1//! Reporting facilities for `bitbelay`.
2
3use std::io::Write;
4
5use chrono::DateTime;
6use chrono::Local;
7use colored::Colorize as _;
8use nonempty::NonEmpty;
9use textwrap::Options;
10
11mod builder;
12mod config;
13pub mod section;
14
15pub use builder::Builder;
16pub use config::Config;
17pub use section::Section;
18
19use crate::section::test::Module;
20
21// NOTE: though it is not statically checked, each of the [`&str`] below should
22// all be one character in length. They were declared as [`&str`] instead of
23// [`char`] to simplify the code and reduce the number of allocations needed.
24// Just keep in mind that all of the math when generating these reports assumes
25// these are one character!
26
27/// The character to enclose a title in when writing a title block.
28const TITLE_BLOCK_CHAR: &str = "#";
29
30/// The divider section within a section.
31const SECTION_DIVIDER_CHAR: &str = "=";
32/// The character to use when creating a horizontal rule within a section.
33const SECTION_HR_CHAR: &str = "-";
34/// The gutter characters (used on the left and right) to frame a section.
35const SECTION_VERTICAL_BLOCK_CHAR: &str = "|";
36
37/// A report for a suite of tests.
38///
39/// The report is comprised of a few elements:
40///
41/// * The title of the test suite.
42/// * The date that the test suite was run.
43/// * The sections within the report.
44#[derive(Debug)]
45pub struct Report {
46 /// The title of the test suite.
47 title: String,
48
49 /// The date that the test suite was run.
50 date: DateTime<Local>,
51
52 /// The sections within the report.
53 sections: NonEmpty<Section>,
54}
55
56impl Report {
57 /// Gets the title from the [`Report`].
58 ///
59 /// # Examples
60 ///
61 /// ```
62 /// use bitbelay_report::section::test;
63 /// use bitbelay_report::section::test::module::Result;
64 /// use bitbelay_report::section::test::Module;
65 /// use bitbelay_report::Builder;
66 ///
67 /// let result = test::Builder::default()
68 /// .title("Foo")?
69 /// .description("Bar")?
70 /// .push_module(Module::new(Result::Inconclusive, "Baz", None, None))
71 /// .try_build()?;
72 ///
73 /// let report = Builder::default()
74 /// .title("Hello, world!")?
75 /// .push_test_result(result)
76 /// .try_build()?;
77 ///
78 /// assert_eq!(report.title(), "Hello, world!");
79 ///
80 /// # Ok::<(), Box<dyn std::error::Error>>(())
81 /// ```
82 pub fn title(&self) -> &str {
83 self.title.as_ref()
84 }
85
86 /// Gets the date from the [`Report`].
87 ///
88 /// # Examples
89 ///
90 /// ```
91 /// use bitbelay_report::section::test;
92 /// use bitbelay_report::section::test::module::Result;
93 /// use bitbelay_report::section::test::Module;
94 /// use bitbelay_report::Builder;
95 /// use chrono::Local;
96 ///
97 /// let result = test::Builder::default()
98 /// .title("Foo")?
99 /// .description("Bar")?
100 /// .push_module(Module::new(Result::Inconclusive, "Baz", None, None))
101 /// .try_build()?;
102 ///
103 /// let report = Builder::default()
104 /// .title("Hello, world!")?
105 /// .push_test_result(result.clone())
106 /// .try_build()?;
107 ///
108 /// assert_eq!(
109 /// Local::now().naive_local().date(),
110 /// report.date().naive_local().date()
111 /// );
112 ///
113 /// # Ok::<(), Box<dyn std::error::Error>>(())
114 /// ```
115 pub fn date(&self) -> DateTime<Local> {
116 self.date
117 }
118
119 /// Gets the sections from a [`Report`].
120 ///
121 /// # Examples
122 ///
123 /// ```
124 /// use bitbelay_report::section::test;
125 /// use bitbelay_report::section::test::module::Result;
126 /// use bitbelay_report::section::test::Module;
127 /// use bitbelay_report::Builder;
128 ///
129 /// let result = test::Builder::default()
130 /// .title("Foo")?
131 /// .description("Bar")?
132 /// .push_module(Module::new(Result::Inconclusive, "Baz", None, None))
133 /// .try_build()?;
134 ///
135 /// let report = Builder::default()
136 /// .title("Hello, world!")?
137 /// .push_test_result(result.clone())
138 /// .try_build()?;
139 ///
140 /// assert_eq!(report.sections().len(), 1);
141 /// assert_eq!(report.sections().first().as_test_result().unwrap(), &result);
142 ///
143 /// # Ok::<(), Box<dyn std::error::Error>>(())
144 /// ```
145 pub fn sections(&self) -> &NonEmpty<Section> {
146 &self.sections
147 }
148
149 /// Writes the report to a [writer](std::io::Write).
150 ///
151 /// # Examples
152 ///
153 /// ```
154 /// use bitbelay_report::section::test;
155 /// use bitbelay_report::section::test::module::Result;
156 /// use bitbelay_report::section::test::Module;
157 /// use bitbelay_report::Builder;
158 /// use bitbelay_report::Config;
159 ///
160 /// let result = test::Builder::default()
161 /// .title("Foo")?
162 /// .description("Bar")?
163 /// .push_module(Module::new(Result::Inconclusive, "Baz", None, None))
164 /// .try_build()?;
165 ///
166 /// let report = Builder::default()
167 /// .title("Hello, world!")?
168 /// .push_test_result(result.clone())
169 /// .try_build()?;
170 ///
171 /// let mut buffer = Vec::new();
172 /// report.write_to(&mut buffer, &Config::default())?;
173 ///
174 /// # Ok::<(), Box<dyn std::error::Error>>(())
175 /// ```
176 pub fn write_to<W: Write>(&self, writer: &mut W, config: &Config) -> std::io::Result<()> {
177 write_title_block(writer, &format!("{} Test Suite", &self.title), config)?;
178 write_centered_line(writer, &format!("Date: {:#?}", self.date), config)?;
179
180 for section in &self.sections {
181 writeln!(writer)?;
182 match section {
183 Section::TestResult(section) => write_test_result(writer, section, config)?,
184 }
185 }
186
187 Ok(())
188 }
189}
190
191//=============//
192// Foundations //
193//=============//
194
195/// Gets the length of the visible string that will be printed to the terminal.
196///
197/// In other words, it does not count terminal control sequences that modify the
198/// style or color of the text.
199fn visible_length(s: &str) -> usize {
200 let mut length = 0;
201 let mut in_escape_sequence = false;
202
203 for char in s.chars() {
204 match char {
205 '\u{1b}' => in_escape_sequence = true,
206 'a'..='z' | 'A'..='Z' if in_escape_sequence => in_escape_sequence = false,
207 _ if !in_escape_sequence => length += 1,
208 _ => {}
209 }
210 }
211
212 length
213}
214
215/// Gets the required padding to center a line within a report.
216fn get_padding(element_width: usize, config: &Config) -> usize {
217 if element_width > config.width() {
218 0
219 } else {
220 (config.width() - element_width) / 2
221 }
222}
223
224//=========//
225// General //
226//=========//
227
228/// Writes a centered line within a report of a given configuration.
229fn write_centered_line<W: Write>(
230 writer: &mut W,
231 line: &str,
232 config: &Config,
233) -> std::io::Result<()> {
234 let padding = get_padding(visible_length(line), config);
235
236 writeln!(
237 writer,
238 "{:padding$}{}{:padding$}",
239 "",
240 line,
241 "",
242 padding = padding
243 )?;
244
245 Ok(())
246}
247
248//=======//
249// Title //
250//=======//
251
252/// Prints the title block for a report.
253fn write_title_block<W: Write>(writer: &mut W, line: &str, config: &Config) -> std::io::Result<()> {
254 let element_width = visible_length(line) + 4; // Two spaces and two block chars.
255
256 if config.width() < element_width {
257 panic!(
258 "total width ({}) too small to print block ({})",
259 config.width(),
260 element_width
261 );
262 }
263
264 write_centered_line(writer, &TITLE_BLOCK_CHAR.repeat(element_width), config)?;
265 write_centered_line(
266 writer,
267 &format!("{} {} {}", &TITLE_BLOCK_CHAR, line, &TITLE_BLOCK_CHAR),
268 config,
269 )?;
270 write_centered_line(writer, &TITLE_BLOCK_CHAR.repeat(element_width), config)?;
271 writeln!(writer)
272}
273
274//==========//
275// Sections //
276//==========//
277
278/// Writes the start of a new section within the report.
279fn write_section_start<W: Write>(writer: &mut W, config: &Config) -> std::io::Result<()> {
280 writeln!(
281 writer,
282 "/{}\\",
283 SECTION_DIVIDER_CHAR.repeat(config.width() - 2)
284 ) // The two slashes.
285}
286
287/// Writes the end of a section within the report.
288fn write_section_end<W: Write>(writer: &mut W, config: &Config) -> std::io::Result<()> {
289 writeln!(
290 writer,
291 "\\{}/",
292 SECTION_DIVIDER_CHAR.repeat(config.width() - 2)
293 ) // The two slashes.
294}
295
296/// Writes a line within a section of the report.
297fn write_section_line<W: Write>(
298 writer: &mut W,
299 line: &str,
300 config: &Config,
301) -> std::io::Result<()> {
302 let line_len = visible_length(line) + 4; // Two spaces and two description block chars.
303
304 if config.width() < line_len {
305 panic!(
306 "description line length ({}) is too long to print within total width ({})",
307 line_len,
308 config.width()
309 )
310 }
311
312 let padding = config.width() - line_len;
313
314 writeln!(
315 writer,
316 "{} {} {:padding$}{}",
317 SECTION_VERTICAL_BLOCK_CHAR,
318 line,
319 "",
320 SECTION_VERTICAL_BLOCK_CHAR,
321 padding = padding
322 )
323}
324
325/// Writes lines within a section of the report while wrapping lines as
326/// necessary.
327///
328/// # Notes
329///
330/// * Unforunately, there is no good way that I could think of to not count
331/// terminal control sequences here: `textwrap` does not support ignoring
332/// them, and they must be included in the text passed to `textwrap` to be
333/// printed. As such, their length just counts when wrapping lines here,
334/// potentially leading to lines that are wrapped "too early" (because
335/// `textwrap` thinks they are longer than they actually are when displayed).
336fn write_section_wrapped_lines<W: Write>(
337 writer: &mut W,
338 lines: &str,
339 config: &Config,
340) -> std::io::Result<()> {
341 let max_line_length = config.width() - 4; // Two spaces and to description block chars.
342
343 for line in textwrap::wrap(lines, Options::new(max_line_length)) {
344 write_section_line(writer, &line, config)?;
345 }
346
347 Ok(())
348}
349
350//===================//
351// Parts of Sections //
352//===================//
353
354/// Writes a section title.
355fn write_section_title<W: Write>(
356 writer: &mut W,
357 title: &str,
358 config: &Config,
359) -> std::io::Result<()> {
360 let title = format!("{} {}", "#".bold(), title.underline().bold());
361 write_section_line(writer, &title, config)
362}
363
364/// Writes a horizontal rule within a section.
365fn write_section_hr<W: Write>(writer: &mut W, config: &Config) -> std::io::Result<()> {
366 writeln!(
367 writer,
368 "{}{}{}",
369 SECTION_VERTICAL_BLOCK_CHAR,
370 SECTION_HR_CHAR.repeat(config.width() - 2),
371 SECTION_VERTICAL_BLOCK_CHAR
372 )
373}
374
375/// Writes a module within a section.
376fn write_section_module<W: Write>(
377 writer: &mut W,
378 module: &Module,
379 config: &Config,
380) -> std::io::Result<()> {
381 let mut summary_line = format!("[{:#}] {}", module.result(), module.name().bold());
382
383 if let Some(value) = module.value() {
384 summary_line.push_str(&format!(" => {}", value));
385 }
386
387 write_section_line(writer, &summary_line, config)?;
388
389 if let Some(details) = module.details() {
390 write_section_line(writer, "", config)?;
391 write_section_wrapped_lines(writer, details, config)?;
392 }
393
394 Ok(())
395}
396
397/// Writes a full test result section.
398fn write_test_result<W: Write>(
399 writer: &mut W,
400 section: §ion::Test,
401 config: &Config,
402) -> std::io::Result<()> {
403 // Header.
404 write_section_start(writer, config)?;
405 write_section_line(writer, "", config)?;
406 write_section_title(writer, section.title(), config)?;
407 write_section_line(writer, "", config)?;
408 write_section_hr(writer, config)?;
409
410 // Description.
411 if config.write_test_result_descriptions() {
412 write_section_line(writer, "", config)?;
413 write_section_wrapped_lines(writer, section.description(), config)?;
414 write_section_line(writer, "", config)?;
415 write_section_hr(writer, config)?;
416 }
417
418 // Modules.
419 write_section_line(writer, "", config)?;
420 for module in section.modules() {
421 write_section_module(writer, module, config)?;
422 }
423
424 // Footer.
425 write_section_line(writer, "", config)?;
426 write_section_end(writer, config)?;
427
428 Ok(())
429}