use std::fmt::Write as _;
use std::fs::OpenOptions;
use std::io::ErrorKind;
use std::io::Write as _;
use crate::error::{Error, Result};
const MAX_BYTES: usize = 1024 * 1024;
fn esc_text(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn esc_attr(s: &str) -> String {
esc_text(s).replace('"', """)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SummaryText {
Escaped(String),
Html(String),
}
impl SummaryText {
#[must_use]
pub fn escaped(text: impl Into<String>) -> Self {
Self::Escaped(text.into())
}
#[must_use]
pub fn html(html: impl Into<String>) -> Self {
Self::Html(html.into())
}
fn into_html(self) -> String {
match self {
SummaryText::Escaped(text) => esc_text(&text),
SummaryText::Html(html) => html,
}
}
}
impl From<&str> for SummaryText {
fn from(text: &str) -> Self {
SummaryText::escaped(text)
}
}
impl From<&String> for SummaryText {
fn from(text: &String) -> Self {
SummaryText::escaped(text.clone())
}
}
impl From<String> for SummaryText {
fn from(text: String) -> Self {
SummaryText::escaped(text)
}
}
#[derive(Debug, Clone)]
pub struct Cell {
data: SummaryText,
header: bool,
colspan: u32,
rowspan: u32,
}
impl Cell {
#[must_use]
pub fn new(data: impl Into<SummaryText>) -> Self {
Self {
data: data.into(),
header: false,
colspan: 1,
rowspan: 1,
}
}
#[must_use]
pub fn header(data: impl Into<SummaryText>) -> Self {
Self {
header: true,
..Self::new(data)
}
}
#[must_use]
pub fn colspan(mut self, n: u32) -> Self {
self.colspan = n.max(1);
self
}
#[must_use]
pub fn rowspan(mut self, n: u32) -> Self {
self.rowspan = n.max(1);
self
}
}
impl From<&str> for Cell {
fn from(s: &str) -> Self {
Cell::new(s)
}
}
impl From<String> for Cell {
fn from(s: String) -> Self {
Cell::new(s)
}
}
impl From<SummaryText> for Cell {
fn from(text: SummaryText) -> Self {
Cell::new(text)
}
}
#[derive(Debug, Clone, Default)]
pub struct Summary {
buf: String,
}
impl Summary {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn raw(&mut self, text: impl AsRef<str>, eol: bool) -> &mut Self {
self.buf.push_str(text.as_ref());
if eol {
self.buf.push('\n');
}
self
}
pub fn eol(&mut self) -> &mut Self {
self.buf.push('\n');
self
}
pub fn heading(&mut self, text: impl Into<SummaryText>, level: u8) -> &mut Self {
let l = level.clamp(1, 6);
let text = text.into().into_html();
let _ = writeln!(self.buf, "<h{l}>{text}</h{l}>");
self
}
pub fn code_block(&mut self, code: impl AsRef<str>, lang: Option<&str>) -> &mut Self {
let code = esc_text(code.as_ref());
match lang {
Some(l) => {
let _ = writeln!(
self.buf,
"<pre lang=\"{}\"><code>{code}</code></pre>",
esc_attr(l)
);
}
None => {
let _ = writeln!(self.buf, "<pre><code>{code}</code></pre>");
}
}
self
}
pub fn list<I, S>(&mut self, items: I, ordered: bool) -> &mut Self
where
I: IntoIterator<Item = S>,
S: Into<SummaryText>,
{
let tag = if ordered { "ol" } else { "ul" };
self.buf.push('<');
self.buf.push_str(tag);
self.buf.push('>');
for item in items {
let _ = write!(self.buf, "<li>{}</li>", item.into().into_html());
}
let _ = writeln!(self.buf, "</{tag}>");
self
}
pub fn table(&mut self, rows: impl IntoIterator<Item = Vec<Cell>>) -> &mut Self {
self.buf.push_str("<table>");
for row in rows {
self.buf.push_str("<tr>");
for cell in row {
let tag = if cell.header { "th" } else { "td" };
let _ = write!(
self.buf,
"<{tag} colspan=\"{}\" rowspan=\"{}\">{}</{tag}>",
cell.colspan,
cell.rowspan,
cell.data.into_html()
);
}
self.buf.push_str("</tr>");
}
self.buf.push_str("</table>\n");
self
}
pub fn details(
&mut self,
label: impl Into<SummaryText>,
content: impl Into<SummaryText>,
) -> &mut Self {
let label = label.into().into_html();
let content = content.into().into_html();
let _ = writeln!(
self.buf,
"<details><summary>{}</summary>{}</details>",
label, content
);
self
}
pub fn image(
&mut self,
src: impl AsRef<str>,
alt: impl AsRef<str>,
size: Option<(u32, u32)>,
) -> &mut Self {
self.buf.push_str("<img src=\"");
self.buf.push_str(&esc_attr(src.as_ref()));
self.buf.push_str("\" alt=\"");
self.buf.push_str(&esc_attr(alt.as_ref()));
self.buf.push('"');
if let Some((w, h)) = size {
let _ = write!(self.buf, " width=\"{w}\" height=\"{h}\"");
}
self.buf.push_str(">\n");
self
}
pub fn link(&mut self, text: impl Into<SummaryText>, href: impl AsRef<str>) -> &mut Self {
let text = text.into().into_html();
let _ = writeln!(
self.buf,
"<a href=\"{}\">{}</a>",
esc_attr(href.as_ref()),
text
);
self
}
pub fn quote(&mut self, text: impl Into<SummaryText>, cite: Option<&str>) -> &mut Self {
let text = text.into().into_html();
match cite {
Some(c) => {
let _ = writeln!(
self.buf,
"<blockquote cite=\"{}\">{}</blockquote>",
esc_attr(c),
text
);
}
None => {
let _ = writeln!(self.buf, "<blockquote>{text}</blockquote>");
}
}
self
}
pub fn separator(&mut self) -> &mut Self {
self.buf.push_str("<hr>\n");
self
}
pub fn break_(&mut self) -> &mut Self {
self.buf.push_str("<br>\n");
self
}
#[must_use]
pub fn stringify(&self) -> &str {
&self.buf
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.buf.is_empty()
}
pub fn clear(&mut self) -> &mut Self {
self.buf.clear();
self
}
fn write_inner(&mut self, append: bool) -> Result<()> {
let write_bytes = self.buf.len() as u64;
if write_bytes > MAX_BYTES as u64 {
return Err(Error::SummaryTooLarge {
bytes: self.buf.len(),
});
}
let Some(path) = std::env::var_os("GITHUB_STEP_SUMMARY") else {
return Ok(());
};
let existing_bytes = if append {
match std::fs::metadata(&path) {
Ok(meta) => meta.len(),
Err(err) if err.kind() == ErrorKind::NotFound => 0,
Err(err) => return Err(err.into()),
}
} else {
0
};
let total_bytes = existing_bytes.saturating_add(write_bytes);
if total_bytes > MAX_BYTES as u64 {
return Err(Error::SummaryTooLarge {
bytes: usize::try_from(total_bytes).unwrap_or(usize::MAX),
});
}
let mut file = OpenOptions::new()
.create(true)
.append(append)
.write(true)
.truncate(!append)
.open(path)?;
file.write_all(self.buf.as_bytes())?;
self.clear();
Ok(())
}
pub fn write(&mut self) -> Result<()> {
self.write_inner(true)
}
pub fn write_overwrite(&mut self) -> Result<()> {
self.write_inner(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heading_clamps_level() {
let mut s = Summary::new();
s.heading("Top", 9);
assert_eq!(s.stringify(), "<h6>Top</h6>\n");
}
#[test]
fn html_metachars_are_escaped() {
let mut s = Summary::new();
s.code_block("DEMO_FLAG<<d & a>b", None);
assert_eq!(
s.stringify(),
"<pre><code>DEMO_FLAG<<d & a>b</code></pre>\n"
);
let mut h = Summary::new();
h.heading("a < b & c", 2);
assert_eq!(h.stringify(), "<h2>a < b & c</h2>\n");
let mut l = Summary::new();
l.link("x", "https://e.com/?a=1\"&b=2");
assert_eq!(
l.stringify(),
"<a href=\"https://e.com/?a=1"&b=2\">x</a>\n"
);
let mut r = Summary::new();
r.raw("<b>kept</b>", false);
assert_eq!(r.stringify(), "<b>kept</b>");
}
#[test]
fn raw_html_is_opt_in() {
let mut s = Summary::new();
s.details(
SummaryText::html("<b>open</b>"),
SummaryText::html("<p>surprise</p>"),
);
assert_eq!(
s.stringify(),
"<details><summary><b>open</b></summary><p>surprise</p></details>\n"
);
}
#[test]
fn chaining_builds_expected_html() {
let mut s = Summary::new();
s.heading("Report", 2)
.list(["a", "b"], false)
.code_block("cargo test", Some("sh"))
.separator();
assert_eq!(
s.stringify(),
"<h2>Report</h2>\n<ul><li>a</li><li>b</li></ul>\n\
<pre lang=\"sh\"><code>cargo test</code></pre>\n<hr>\n"
);
}
#[test]
fn table_with_header_and_spans() {
let mut s = Summary::new();
s.table([
vec![Cell::header("H1"), Cell::header("H2")],
vec![Cell::new("a").colspan(2)],
]);
assert_eq!(
s.stringify(),
"<table><tr><th colspan=\"1\" rowspan=\"1\">H1</th>\
<th colspan=\"1\" rowspan=\"1\">H2</th></tr>\
<tr><td colspan=\"2\" rowspan=\"1\">a</td></tr></table>\n"
);
}
#[test]
fn span_zero_is_clamped_to_one() {
let mut s = Summary::new();
s.table([vec![Cell::new("x").colspan(0).rowspan(0)]]);
assert_eq!(
s.stringify(),
"<table><tr><td colspan=\"1\" rowspan=\"1\">x</td></tr></table>\n"
);
}
#[test]
fn oversized_buffer_rejected() {
let mut s = Summary::new();
s.raw("x".repeat(MAX_BYTES + 1), false);
let e = s.write_overwrite().unwrap_err();
assert!(matches!(e, Error::SummaryTooLarge { bytes } if bytes == MAX_BYTES + 1));
}
#[test]
fn empty_and_clear() {
let mut s = Summary::new();
assert!(s.is_empty());
s.raw("hi", true);
assert!(!s.is_empty());
s.clear();
assert!(s.is_empty());
}
}