use std::io::{self, Write};
use anyhow::Result;
use serde::Serialize;
use super::gate::{Finding, Level};
use crate::cli::DiffFormat;
use crate::render;
#[derive(Debug, Serialize)]
pub struct Report<'a> {
pub result: &'static str,
pub files: usize,
pub policy: &'a str,
pub repository_policy: Option<&'a str>,
pub findings: &'a [Finding],
}
impl<'a> Report<'a> {
pub fn new(
findings: &'a [Finding],
files: usize,
policy: &'a str,
repository_policy: Option<&'a str>,
) -> Self {
Self {
result: worst(findings).map_or("clean", Level::as_str),
files,
policy,
repository_policy,
findings,
}
}
}
pub fn worst(findings: &[Finding]) -> Option<Level> {
findings.iter().map(|f| f.level).max()
}
pub fn write(format: DiffFormat, report: &Report<'_>) -> Result<()> {
let mut out = io::stdout().lock();
match format {
DiffFormat::Json => render::json(report)?,
DiffFormat::Text => text(&mut out, report)?,
DiffFormat::Github => {
for finding in report.findings {
writeln!(out, "{}", annotation(finding))?;
}
text(&mut out, report)?;
}
}
Ok(())
}
fn where_of(finding: &Finding) -> String {
let path = if finding.path.is_empty() {
"(the diff)"
} else {
&finding.path
};
match finding.line {
Some(line) => format!("{path}:{line}"),
None => path.to_owned(),
}
}
pub fn text(out: &mut impl Write, report: &Report<'_>) -> io::Result<()> {
for finding in report.findings {
writeln!(
out,
"{:<7} {} [{}]",
finding.level.as_str(),
render::clean(&where_of(finding)),
finding.rules.join(", ")
)?;
for reason in &finding.reasons {
writeln!(out, " {}", render::clean(reason))?;
}
}
let count = |level| report.findings.iter().filter(|f| f.level == level).count();
let checked = match report.repository_policy {
Some(repo) => format!("{} and {}", report.policy, render::clean(repo)),
None => report.policy.to_owned(),
};
match report.result {
"clean" => writeln!(
out,
"clean: {} changed files, nothing {checked} protects",
report.files
),
_ => writeln!(
out,
"{}: {} deny, {} review in {} changed files (checked against {checked})",
report.result,
count(Level::Deny),
count(Level::Review),
report.files
),
}
}
pub fn annotation(finding: &Finding) -> String {
let command = match finding.level {
Level::Deny => "error",
Level::Review => "warning",
};
let mut properties = Vec::new();
if !finding.path.is_empty() {
properties.push(format!("file={}", property(&finding.path)));
}
if let Some(line) = finding.line {
properties.push(format!("line={line}"));
}
let title = format!(
"OpenMoat {}: {}",
finding.level.as_str(),
finding.rules.join(", ")
);
properties.push(format!("title={}", property(&title)));
format!(
"::{command} {}::{}",
properties.join(","),
data(&finding.reasons.join("; "))
)
}
fn data(text: &str) -> String {
text.replace('%', "%25")
.replace('\r', "%0D")
.replace('\n', "%0A")
}
fn property(text: &str) -> String {
data(text).replace(':', "%3A").replace(',', "%2C")
}