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: &section::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}