Skip to main content

actions_rs/
summary.rs

1//! Fluent builder for the job summary (`GITHUB_STEP_SUMMARY`).
2//!
3//! The summary is GitHub-Flavored Markdown with embedded HTML.\
4//! Buffer construction is pure (testable via [`Summary::stringify`]);
5//! only [`Summary::write`] / [`Summary::write_overwrite`] touch the filesystem and can fail.
6//!
7//! Text node content is escaped by default.\
8//! Use [`SummaryText::html`] or [`Summary::raw`] when you intentionally want raw HTML parity with `@actions/core`.
9
10use std::fmt::Write as _;
11use std::fs::OpenOptions;
12use std::io::ErrorKind;
13use std::io::Write as _;
14
15use crate::error::{Error, Result};
16
17/// GitHub's documented per-step summary size limit (1 MiB).
18const MAX_BYTES: usize = 1024 * 1024;
19
20/// Escape text destined for HTML element content.
21/// Without this, content like `DEMO_FLAG<<delim` is parsed by the browser as a bogus tag and truncated.
22fn esc_text(s: &str) -> String {
23    s.replace('&', "&amp;")
24        .replace('<', "&lt;")
25        .replace('>', "&gt;")
26}
27
28/// Escape text destined for a double-quoted HTML attribute value.
29fn esc_attr(s: &str) -> String {
30    esc_text(s).replace('"', "&quot;")
31}
32
33/// Text destined for a summary element.
34///
35/// # Examples
36///
37/// ```
38/// use actions_rs::{Summary, SummaryText};
39///
40/// let mut s = Summary::new();
41/// s.heading(SummaryText::escaped("a<b"), 2)         // escaped
42///  .heading(SummaryText::html("<em>raw</em>"), 3);  // verbatim
43/// assert_eq!(s.stringify(), "<h2>a&lt;b</h2>\n<h3><em>raw</em></h3>\n");
44/// ```
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum SummaryText {
47    /// Escape HTML metacharacters before rendering.
48    Escaped(String),
49    /// Insert trusted HTML verbatim.
50    Html(String),
51}
52
53impl SummaryText {
54    /// Escape `text` before rendering it into an element body.
55    ///
56    /// # Examples
57    ///
58    /// ```
59    /// use actions_rs::{Summary, SummaryText};
60    /// let mut s = Summary::new();
61    /// s.heading(SummaryText::escaped("1 < 2 & ok"), 1);
62    /// assert_eq!(s.stringify(), "<h1>1 &lt; 2 &amp; ok</h1>\n");
63    /// ```
64    #[must_use]
65    pub fn escaped(text: impl Into<String>) -> Self {
66        Self::Escaped(text.into())
67    }
68
69    /// Insert trusted HTML verbatim into an element body.
70    ///
71    /// # Examples
72    ///
73    /// ```
74    /// use actions_rs::{Summary, SummaryText};
75    /// let mut s = Summary::new();
76    /// s.heading(SummaryText::html("<strong>done</strong>"), 2);
77    /// assert_eq!(s.stringify(), "<h2><strong>done</strong></h2>\n");
78    /// ```
79    #[must_use]
80    pub fn html(html: impl Into<String>) -> Self {
81        Self::Html(html.into())
82    }
83
84    fn into_html(self) -> String {
85        match self {
86            SummaryText::Escaped(text) => esc_text(&text),
87            SummaryText::Html(html) => html,
88        }
89    }
90}
91
92impl From<&str> for SummaryText {
93    fn from(text: &str) -> Self {
94        SummaryText::escaped(text)
95    }
96}
97
98impl From<&String> for SummaryText {
99    fn from(text: &String) -> Self {
100        SummaryText::escaped(text.clone())
101    }
102}
103
104impl From<String> for SummaryText {
105    fn from(text: String) -> Self {
106        SummaryText::escaped(text)
107    }
108}
109
110/// A table cell.
111/// Use [`Cell::header`] for `<th>`; `colspan`/`rowspan` map to the matching HTML attributes.
112/// Cell content is escaped unless you pass [`SummaryText::html`].
113///
114/// # Examples
115///
116/// ```
117/// use actions_rs::{Cell, Summary};
118/// let mut s = Summary::new();
119/// s.table([vec![Cell::header("Name"), Cell::new("value")]]);
120/// assert!(s.stringify().contains(r#"<th colspan="1" rowspan="1">Name</th>"#));
121/// ```
122#[derive(Debug, Clone)]
123pub struct Cell {
124    data: SummaryText,
125    header: bool,
126    colspan: u32,
127    rowspan: u32,
128}
129
130impl Cell {
131    /// A `<td>` cell.
132    ///
133    /// # Examples
134    ///
135    /// ```
136    /// use actions_rs::{Cell, Summary};
137    /// let mut s = Summary::new();
138    /// s.table([vec![Cell::new("x")]]);
139    /// assert_eq!(s.stringify(), "<table><tr><td colspan=\"1\" rowspan=\"1\">x</td></tr></table>\n");
140    /// ```
141    #[must_use]
142    pub fn new(data: impl Into<SummaryText>) -> Self {
143        Self {
144            data: data.into(),
145            header: false,
146            colspan: 1,
147            rowspan: 1,
148        }
149    }
150
151    /// A `<th>` header cell.
152    ///
153    /// # Examples
154    ///
155    /// ```
156    /// use actions_rs::{Cell, Summary};
157    /// let mut s = Summary::new();
158    /// s.table([vec![Cell::header("Col")]]);
159    /// assert_eq!(s.stringify(), "<table><tr><th colspan=\"1\" rowspan=\"1\">Col</th></tr></table>\n");
160    /// ```
161    #[must_use]
162    pub fn header(data: impl Into<SummaryText>) -> Self {
163        Self {
164            header: true,
165            ..Self::new(data)
166        }
167    }
168
169    /// Set the column span (clamped to ≥ 1; the HTML spec forbids 0).
170    ///
171    /// # Examples
172    ///
173    /// ```
174    /// use actions_rs::{Cell, Summary};
175    /// let mut s = Summary::new();
176    /// s.table([vec![Cell::new("wide").colspan(0)]]); // 0 clamps to 1
177    /// assert!(s.stringify().contains(r#"colspan="1""#));
178    /// ```
179    #[must_use]
180    pub fn colspan(mut self, n: u32) -> Self {
181        self.colspan = n.max(1);
182        self
183    }
184
185    /// Set the row span (clamped to ≥ 1; the HTML spec forbids 0).
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// use actions_rs::{Cell, Summary};
191    /// let mut s = Summary::new();
192    /// s.table([vec![Cell::new("tall").rowspan(3)]]);
193    /// assert!(s.stringify().contains(r#"rowspan="3""#));
194    /// ```
195    #[must_use]
196    pub fn rowspan(mut self, n: u32) -> Self {
197        self.rowspan = n.max(1);
198        self
199    }
200}
201
202impl From<&str> for Cell {
203    fn from(s: &str) -> Self {
204        Cell::new(s)
205    }
206}
207
208impl From<String> for Cell {
209    fn from(s: String) -> Self {
210        Cell::new(s)
211    }
212}
213
214impl From<SummaryText> for Cell {
215    fn from(text: SummaryText) -> Self {
216        Cell::new(text)
217    }
218}
219
220/// Accumulating job-summary builder.
221/// Chain the builder methods, then [`write`](Summary::write) (append) or [`write_overwrite`](Summary::write_overwrite).
222/// Building is pure and inspectable via [`stringify`](Summary::stringify);
223/// only the `write*` methods touch `GITHUB_STEP_SUMMARY`.
224///
225/// # Examples
226///
227/// ```
228/// use actions_rs::Summary;
229///
230/// let mut s = Summary::new();
231/// s.heading("Build", 2)
232///     .code_block("cargo test", Some("sh"));
233///
234/// assert_eq!(
235///     s.stringify(),
236///     "<h2>Build</h2>\n<pre lang=\"sh\"><code>cargo test</code></pre>\n"
237/// );
238///
239/// // In a real action you would then persist it:
240/// // s.write()?;  // appends to $GITHUB_STEP_SUMMARY
241/// ```
242#[derive(Debug, Clone, Default)]
243pub struct Summary {
244    buf: String,
245}
246
247impl Summary {
248    /// Create an empty summary buffer.
249    ///
250    /// # Examples
251    ///
252    /// ```
253    /// let s = actions_rs::Summary::new();
254    /// assert!(s.is_empty());
255    /// ```
256    #[must_use]
257    pub fn new() -> Self {
258        Self::default()
259    }
260
261    /// Append raw text **without HTML escaping**.
262    /// When `eol` is true a newline is appended after it.
263    ///
264    /// # Safety
265    /// This is the one builder method that does not escape `& < > "`.
266    /// Passing untrusted input here is an HTML-injection vector.
267    /// Use it only for trusted or already-escaped markup;
268    /// for arbitrary text prefer [`Summary::code_block`] / [`Summary::heading`] etc., which escape.
269    ///
270    /// # Examples
271    ///
272    /// ```
273    /// let mut s = actions_rs::Summary::new();
274    /// s.raw("<div class=\"trusted\">ok</div>", true);
275    /// assert_eq!(s.stringify(), "<div class=\"trusted\">ok</div>\n");
276    /// ```
277    pub fn raw(&mut self, text: impl AsRef<str>, eol: bool) -> &mut Self {
278        self.buf.push_str(text.as_ref());
279        if eol {
280            self.buf.push('\n');
281        }
282        self
283    }
284
285    /// Append a newline.
286    ///
287    /// # Examples
288    ///
289    /// ```
290    /// let mut s = actions_rs::Summary::new();
291    /// s.raw("a", false).eol().raw("b", false);
292    /// assert_eq!(s.stringify(), "a\nb");
293    /// ```
294    pub fn eol(&mut self) -> &mut Self {
295        self.buf.push('\n');
296        self
297    }
298
299    /// Append an `<h1>`–`<h6>` heading (`level` clamped to `1..=6`).
300    /// Text is escaped unless you pass [`SummaryText::html`].
301    ///
302    /// # Examples
303    ///
304    /// ```
305    /// let mut s = actions_rs::Summary::new();
306    /// s.heading("Result", 9); // level clamps to 6
307    /// assert_eq!(s.stringify(), "<h6>Result</h6>\n");
308    /// ```
309    pub fn heading(&mut self, text: impl Into<SummaryText>, level: u8) -> &mut Self {
310        let l = level.clamp(1, 6);
311        let text = text.into().into_html();
312        let _ = writeln!(self.buf, "<h{l}>{text}</h{l}>");
313        self
314    }
315
316    /// Append a fenced `<pre><code>` block with an optional language hint.
317    ///
318    /// # Examples
319    ///
320    /// ```
321    /// let mut s = actions_rs::Summary::new();
322    /// s.code_block("cargo test", Some("sh"));
323    /// assert_eq!(s.stringify(), "<pre lang=\"sh\"><code>cargo test</code></pre>\n");
324    /// ```
325    pub fn code_block(&mut self, code: impl AsRef<str>, lang: Option<&str>) -> &mut Self {
326        let code = esc_text(code.as_ref());
327        match lang {
328            Some(l) => {
329                let _ = writeln!(
330                    self.buf,
331                    "<pre lang=\"{}\"><code>{code}</code></pre>",
332                    esc_attr(l)
333                );
334            }
335            None => {
336                let _ = writeln!(self.buf, "<pre><code>{code}</code></pre>");
337            }
338        }
339        self
340    }
341
342    /// Append a `<ul>` (or `<ol>` when `ordered`) of `items`.
343    ///
344    /// # Examples
345    ///
346    /// ```
347    /// let mut s = actions_rs::Summary::new();
348    /// s.list(["a", "b"], false);
349    /// assert_eq!(s.stringify(), "<ul><li>a</li><li>b</li></ul>\n");
350    /// ```
351    pub fn list<I, S>(&mut self, items: I, ordered: bool) -> &mut Self
352    where
353        I: IntoIterator<Item = S>,
354        S: Into<SummaryText>,
355    {
356        let tag = if ordered { "ol" } else { "ul" };
357        self.buf.push('<');
358        self.buf.push_str(tag);
359        self.buf.push('>');
360        for item in items {
361            let _ = write!(self.buf, "<li>{}</li>", item.into().into_html());
362        }
363        let _ = writeln!(self.buf, "</{tag}>");
364        self
365    }
366
367    /// Append a `<table>`.
368    /// Each row is a list of [`Cell`]s.
369    ///
370    /// # Examples
371    ///
372    /// ```
373    /// use actions_rs::{Cell, Summary};
374    /// let mut s = Summary::new();
375    /// s.table([vec![Cell::header("k"), Cell::new("v")]]);
376    /// assert_eq!(
377    ///     s.stringify(),
378    ///     "<table><tr><th colspan=\"1\" rowspan=\"1\">k</th>\
379    ///      <td colspan=\"1\" rowspan=\"1\">v</td></tr></table>\n"
380    /// );
381    /// ```
382    pub fn table(&mut self, rows: impl IntoIterator<Item = Vec<Cell>>) -> &mut Self {
383        self.buf.push_str("<table>");
384        for row in rows {
385            self.buf.push_str("<tr>");
386            for cell in row {
387                let tag = if cell.header { "th" } else { "td" };
388                let _ = write!(
389                    self.buf,
390                    "<{tag} colspan=\"{}\" rowspan=\"{}\">{}</{tag}>",
391                    cell.colspan,
392                    cell.rowspan,
393                    cell.data.into_html()
394                );
395            }
396            self.buf.push_str("</tr>");
397        }
398        self.buf.push_str("</table>\n");
399        self
400    }
401
402    /// Append a `<details>` block with a `<summary>` label.
403    /// Both text nodes are escaped unless you pass [`SummaryText::html`].
404    ///
405    /// # Examples
406    ///
407    /// ```
408    /// let mut s = actions_rs::Summary::new();
409    /// s.details("logs", "all green");
410    /// assert_eq!(s.stringify(), "<details><summary>logs</summary>all green</details>\n");
411    /// ```
412    pub fn details(
413        &mut self,
414        label: impl Into<SummaryText>,
415        content: impl Into<SummaryText>,
416    ) -> &mut Self {
417        let label = label.into().into_html();
418        let content = content.into().into_html();
419        let _ = writeln!(
420            self.buf,
421            "<details><summary>{}</summary>{}</details>",
422            label, content
423        );
424        self
425    }
426
427    /// Append an `<img>`.
428    /// `size` is an optional `(width, height)` in pixels.
429    ///
430    /// # Examples
431    ///
432    /// ```
433    /// let mut s = actions_rs::Summary::new();
434    /// s.image("cov.svg", "coverage", Some((120, 20)));
435    /// assert_eq!(
436    ///     s.stringify(),
437    ///     "<img src=\"cov.svg\" alt=\"coverage\" width=\"120\" height=\"20\">\n"
438    /// );
439    /// ```
440    pub fn image(
441        &mut self,
442        src: impl AsRef<str>,
443        alt: impl AsRef<str>,
444        size: Option<(u32, u32)>,
445    ) -> &mut Self {
446        self.buf.push_str("<img src=\"");
447        self.buf.push_str(&esc_attr(src.as_ref()));
448        self.buf.push_str("\" alt=\"");
449        self.buf.push_str(&esc_attr(alt.as_ref()));
450        self.buf.push('"');
451        if let Some((w, h)) = size {
452            let _ = write!(self.buf, " width=\"{w}\" height=\"{h}\"");
453        }
454        self.buf.push_str(">\n");
455        self
456    }
457
458    /// Append an `<a>` link.
459    /// The link text is escaped unless you pass [`SummaryText::html`];
460    /// `href` is always attribute-escaped.
461    ///
462    /// # Examples
463    ///
464    /// ```
465    /// let mut s = actions_rs::Summary::new();
466    /// s.link("run", "https://example.com/run/1");
467    /// assert_eq!(s.stringify(), "<a href=\"https://example.com/run/1\">run</a>\n");
468    /// ```
469    pub fn link(&mut self, text: impl Into<SummaryText>, href: impl AsRef<str>) -> &mut Self {
470        let text = text.into().into_html();
471        let _ = writeln!(
472            self.buf,
473            "<a href=\"{}\">{}</a>",
474            esc_attr(href.as_ref()),
475            text
476        );
477        self
478    }
479
480    /// Append a `<blockquote>` with an optional `cite` URL.
481    /// Quote text is escaped unless you pass [`SummaryText::html`].
482    ///
483    /// # Examples
484    ///
485    /// ```
486    /// let mut s = actions_rs::Summary::new();
487    /// s.quote("ship it", None);
488    /// assert_eq!(s.stringify(), "<blockquote>ship it</blockquote>\n");
489    /// ```
490    pub fn quote(&mut self, text: impl Into<SummaryText>, cite: Option<&str>) -> &mut Self {
491        let text = text.into().into_html();
492        match cite {
493            Some(c) => {
494                let _ = writeln!(
495                    self.buf,
496                    "<blockquote cite=\"{}\">{}</blockquote>",
497                    esc_attr(c),
498                    text
499                );
500            }
501            None => {
502                let _ = writeln!(self.buf, "<blockquote>{text}</blockquote>");
503            }
504        }
505        self
506    }
507
508    /// Append an `<hr>`.
509    ///
510    /// # Examples
511    ///
512    /// ```
513    /// let mut s = actions_rs::Summary::new();
514    /// s.separator();
515    /// assert_eq!(s.stringify(), "<hr>\n");
516    /// ```
517    pub fn separator(&mut self) -> &mut Self {
518        self.buf.push_str("<hr>\n");
519        self
520    }
521
522    /// Append a `<br>`.
523    ///
524    /// # Examples
525    ///
526    /// ```
527    /// let mut s = actions_rs::Summary::new();
528    /// s.break_();
529    /// assert_eq!(s.stringify(), "<br>\n");
530    /// ```
531    pub fn break_(&mut self) -> &mut Self {
532        self.buf.push_str("<br>\n");
533        self
534    }
535
536    /// The buffered summary content.
537    ///
538    /// # Examples
539    ///
540    /// ```
541    /// let mut s = actions_rs::Summary::new();
542    /// s.heading("Hi", 1);
543    /// assert_eq!(s.stringify(), "<h1>Hi</h1>\n");
544    /// ```
545    #[must_use]
546    pub fn stringify(&self) -> &str {
547        &self.buf
548    }
549
550    /// Whether nothing has been buffered yet.
551    ///
552    /// # Examples
553    ///
554    /// ```
555    /// let mut s = actions_rs::Summary::new();
556    /// assert!(s.is_empty());
557    /// s.eol();
558    /// assert!(!s.is_empty());
559    /// ```
560    #[must_use]
561    pub fn is_empty(&self) -> bool {
562        self.buf.is_empty()
563    }
564
565    /// Clear the buffer (does not touch the file).
566    ///
567    /// # Examples
568    ///
569    /// ```
570    /// let mut s = actions_rs::Summary::new();
571    /// s.heading("x", 1);
572    /// s.clear();
573    /// assert!(s.is_empty());
574    /// ```
575    pub fn clear(&mut self) -> &mut Self {
576        self.buf.clear();
577        self
578    }
579
580    fn write_inner(&mut self, append: bool) -> Result<()> {
581        let write_bytes = self.buf.len() as u64;
582        if write_bytes > MAX_BYTES as u64 {
583            return Err(Error::SummaryTooLarge {
584                bytes: self.buf.len(),
585            });
586        }
587        let Some(path) = std::env::var_os("GITHUB_STEP_SUMMARY") else {
588            // Not in Actions / summaries disabled: nothing to write to. This
589            // is a normal local-run condition, not an error — but the buffer
590            // is *kept* (no write happened, so draining it would lose data
591            // silently). The caller can still `stringify()` or retry.
592            return Ok(());
593        };
594        let existing_bytes = if append {
595            match std::fs::metadata(&path) {
596                Ok(meta) => meta.len(),
597                Err(err) if err.kind() == ErrorKind::NotFound => 0,
598                Err(err) => return Err(err.into()),
599            }
600        } else {
601            0
602        };
603        let total_bytes = existing_bytes.saturating_add(write_bytes);
604        if total_bytes > MAX_BYTES as u64 {
605            return Err(Error::SummaryTooLarge {
606                bytes: usize::try_from(total_bytes).unwrap_or(usize::MAX),
607            });
608        }
609        let mut file = OpenOptions::new()
610            .create(true)
611            .append(append)
612            .write(true)
613            .truncate(!append)
614            .open(path)?;
615        file.write_all(self.buf.as_bytes())?;
616        self.clear();
617        Ok(())
618    }
619
620    /// Append the buffer to the job summary file.
621    ///
622    /// # Errors
623    /// [`Error::SummaryTooLarge`] if the buffer exceeds 1 MiB, or an I/O error.
624    ///
625    /// # Examples
626    ///
627    /// ```no_run
628    /// let mut s = actions_rs::Summary::new();
629    /// s.heading("Build passed", 2);
630    /// s.write()?; // appends to $GITHUB_STEP_SUMMARY
631    /// # Ok::<(), actions_rs::Error>(())
632    /// ```
633    pub fn write(&mut self) -> Result<()> {
634        self.write_inner(true)
635    }
636
637    /// Overwrite the job summary file with the buffer.
638    ///
639    /// # Errors
640    /// [`Error::SummaryTooLarge`] if the buffer exceeds 1 MiB, or an I/O error.
641    ///
642    /// # Examples
643    ///
644    /// ```no_run
645    /// let mut s = actions_rs::Summary::new();
646    /// s.heading("Replaces any prior summary", 2);
647    /// s.write_overwrite()?;
648    /// # Ok::<(), actions_rs::Error>(())
649    /// ```
650    pub fn write_overwrite(&mut self) -> Result<()> {
651        self.write_inner(false)
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    #[test]
660    fn heading_clamps_level() {
661        let mut s = Summary::new();
662        s.heading("Top", 9);
663        assert_eq!(s.stringify(), "<h6>Top</h6>\n");
664    }
665
666    #[test]
667    fn html_metachars_are_escaped() {
668        let mut s = Summary::new();
669        // The exact bug: `DEMO_FLAG<<delim` was eaten by the HTML parser.
670        s.code_block("DEMO_FLAG<<d & a>b", None);
671        assert_eq!(
672            s.stringify(),
673            "<pre><code>DEMO_FLAG&lt;&lt;d &amp; a&gt;b</code></pre>\n"
674        );
675
676        let mut h = Summary::new();
677        h.heading("a < b & c", 2);
678        assert_eq!(h.stringify(), "<h2>a &lt; b &amp; c</h2>\n");
679
680        // Attribute values also escape the double quote.
681        let mut l = Summary::new();
682        l.link("x", "https://e.com/?a=1\"&b=2");
683        assert_eq!(
684            l.stringify(),
685            "<a href=\"https://e.com/?a=1&quot;&amp;b=2\">x</a>\n"
686        );
687
688        // raw() stays raw by contract.
689        let mut r = Summary::new();
690        r.raw("<b>kept</b>", false);
691        assert_eq!(r.stringify(), "<b>kept</b>");
692    }
693
694    #[test]
695    fn raw_html_is_opt_in() {
696        let mut s = Summary::new();
697        s.details(
698            SummaryText::html("<b>open</b>"),
699            SummaryText::html("<p>surprise</p>"),
700        );
701        assert_eq!(
702            s.stringify(),
703            "<details><summary><b>open</b></summary><p>surprise</p></details>\n"
704        );
705    }
706
707    #[test]
708    fn chaining_builds_expected_html() {
709        let mut s = Summary::new();
710        s.heading("Report", 2)
711            .list(["a", "b"], false)
712            .code_block("cargo test", Some("sh"))
713            .separator();
714        assert_eq!(
715            s.stringify(),
716            "<h2>Report</h2>\n<ul><li>a</li><li>b</li></ul>\n\
717             <pre lang=\"sh\"><code>cargo test</code></pre>\n<hr>\n"
718        );
719    }
720
721    #[test]
722    fn table_with_header_and_spans() {
723        let mut s = Summary::new();
724        s.table([
725            vec![Cell::header("H1"), Cell::header("H2")],
726            vec![Cell::new("a").colspan(2)],
727        ]);
728        assert_eq!(
729            s.stringify(),
730            "<table><tr><th colspan=\"1\" rowspan=\"1\">H1</th>\
731             <th colspan=\"1\" rowspan=\"1\">H2</th></tr>\
732             <tr><td colspan=\"2\" rowspan=\"1\">a</td></tr></table>\n"
733        );
734    }
735
736    #[test]
737    fn span_zero_is_clamped_to_one() {
738        let mut s = Summary::new();
739        s.table([vec![Cell::new("x").colspan(0).rowspan(0)]]);
740        assert_eq!(
741            s.stringify(),
742            "<table><tr><td colspan=\"1\" rowspan=\"1\">x</td></tr></table>\n"
743        );
744    }
745
746    #[test]
747    fn oversized_buffer_rejected() {
748        let mut s = Summary::new();
749        s.raw("x".repeat(MAX_BYTES + 1), false);
750        let e = s.write_overwrite().unwrap_err();
751        assert!(matches!(e, Error::SummaryTooLarge { bytes } if bytes == MAX_BYTES + 1));
752    }
753
754    #[test]
755    fn empty_and_clear() {
756        let mut s = Summary::new();
757        assert!(s.is_empty());
758        s.raw("hi", true);
759        assert!(!s.is_empty());
760        s.clear();
761        assert!(s.is_empty());
762    }
763}