use crate::paint::{ColorMode, Role};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Error,
Warn,
Info,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Severity::Error => "error",
Severity::Warn => "warn",
Severity::Info => "info",
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Finding {
pub rule: &'static str,
pub severity: Severity,
pub obligation: Option<String>,
pub path: String,
pub line: usize,
pub column: usize,
pub message: String,
pub remediation: String,
pub patch: Option<crate::Patch>,
}
impl Finding {
pub fn fixable(&self) -> bool {
self.patch.is_some()
}
pub fn order(&self) -> (&str, usize, usize, &str, &str) {
(&self.path, self.line, self.column, self.rule, &self.message)
}
pub fn render(&self, mode: ColorMode) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(
out,
"{} {}",
crate::paint::paint(Role::Path, &self.location(), mode),
crate::paint::severity_word(self.severity, mode)
);
let _ = match &self.obligation {
Some(obligation) => writeln!(
out,
" {} ({}): {}",
self.rule,
crate::paint::paint(Role::Obligation, obligation, mode),
self.message
),
None => writeln!(out, " {}: {}", self.rule, self.message),
};
let _ = writeln!(
out,
" {}{}: {}",
crate::paint::paint(Role::Verb, "fix", mode),
if self.fixable() { " (mechanical)" } else { "" },
self.remediation
);
out
}
fn location(&self) -> String {
match self.line {
0 => self.path.clone(),
line => format!("{}:{line}:{}", self.path, self.column),
}
}
}
pub fn at(span: Option<headwater_yaml::Span>) -> (usize, usize) {
match span {
Some(span) => (span.start.line, span.start.col),
None => (0, 0),
}
}
pub fn sorted(mut findings: Vec<Finding>) -> Vec<Finding> {
findings.sort_by(|a, b| a.order().cmp(&b.order()));
findings
}
#[cfg(test)]
mod tests {
use super::*;
fn finding(path: &str, line: usize, rule: &'static str) -> Finding {
Finding {
rule,
severity: Severity::Error,
obligation: None,
path: path.to_string(),
line,
column: 1,
message: "m".into(),
remediation: "r".into(),
patch: None,
}
}
#[test]
fn findings_sort_by_path_then_line_then_rule() {
let sorted = sorted(vec![
finding("b.md", 1, "z.rule"),
finding("a.md", 9, "a.rule"),
finding("a.md", 2, "z.rule"),
finding("a.md", 2, "a.rule"),
]);
let order: Vec<(&str, usize, &str)> = sorted
.iter()
.map(|f| (f.path.as_str(), f.line, f.rule))
.collect();
assert_eq!(
order,
[
("a.md", 2, "a.rule"),
("a.md", 2, "z.rule"),
("a.md", 9, "a.rule"),
("b.md", 1, "z.rule"),
]
);
}
#[test]
fn a_finding_with_no_line_prints_the_path_alone() {
assert!(finding("a.md", 0, "r")
.render(ColorMode::Plain)
.starts_with("a.md ✗ error\n"));
}
#[test]
fn a_rendered_finding_carries_color_only_under_ansi() {
let mut one = finding("a.md", 3, "r");
one.obligation = Some("OB-001".to_string());
let plain = one.render(ColorMode::Plain);
assert!(!plain.contains('\x1b'), "{plain:?}");
assert!(plain.contains("✗ error"), "{plain:?}");
assert!(plain.contains("(OB-001)"), "{plain:?}");
assert!(plain.contains("fix"), "{plain:?}");
let ansi = one.render(ColorMode::Ansi);
assert!(ansi.contains('\x1b'), "{ansi:?}");
for word in ["a.md:3:1", "error", "OB-001", "fix", "r", "m"] {
assert!(ansi.contains(word), "{ansi:?} is missing {word:?}");
}
}
}