use serde::{Deserialize, Serialize};
use crate::RepoPath;
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
pub enum Severity {
#[default]
Error,
Warning,
}
impl Severity {
pub fn as_str(self) -> &'static str {
match self {
Severity::Error => "error",
Severity::Warning => "warning",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Locator {
pub schema: String,
pub rule: Option<String>,
}
impl Locator {
pub fn schema(id: impl Into<String>) -> Locator {
Locator {
schema: id.into(),
rule: None,
}
}
pub fn at(&self, at: impl Into<String>) -> Locator {
Locator {
schema: self.schema.clone(),
rule: Some(at.into()),
}
}
fn suffix(&self) -> String {
match &self.rule {
None => format!("(schema {})", self.schema),
Some(at) => format!("(schema {}, {at})", self.schema),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Finding {
pub path: RepoPath,
pub line: Option<usize>,
pub severity: Severity,
pub criterion: String,
pub message: String,
#[serde(skip)]
pub rule_line: Option<usize>,
}
impl Finding {
pub fn new(
path: RepoPath,
line: Option<usize>,
severity: Severity,
criterion: &str,
message: impl Into<String>,
) -> Self {
debug_assert!(
is_criterion(criterion),
"`{criterion}` is not a criterion key"
);
Finding {
path,
line,
severity,
criterion: criterion.to_owned(),
message: message.into(),
rule_line: None,
}
}
pub fn error(
path: RepoPath,
line: Option<usize>,
criterion: &str,
message: impl Into<String>,
) -> Self {
Self::new(path, line, Severity::Error, criterion, message)
}
pub fn warning(
path: RepoPath,
line: Option<usize>,
criterion: &str,
message: impl Into<String>,
) -> Self {
Self::new(path, line, Severity::Warning, criterion, message)
}
#[must_use]
pub fn of_rule(mut self, line: usize) -> Self {
self.rule_line = (line > 0).then_some(line);
self
}
#[must_use]
pub fn at(mut self, locator: &Locator) -> Self {
self.message = format!("{} {}", self.message, locator.suffix());
self
}
}
fn is_criterion(key: &str) -> bool {
let Some((head, tail)) = key.rsplit_once('_') else {
return false;
};
let number = tail
.strip_prefix("AC-")
.or_else(|| tail.strip_prefix("P-"))
.unwrap_or(tail);
let (words, part) = head.split_once('-').unwrap_or((head, "1"));
!number.is_empty()
&& number.bytes().all(|b| b.is_ascii_digit())
&& !part.is_empty()
&& part.bytes().all(|b| b.is_ascii_digit())
&& words
.split('_')
.all(|w| !w.is_empty() && w.bytes().all(|b| b.is_ascii_uppercase()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Problem {
pub criterion: &'static str,
pub message: String,
}
impl Problem {
pub fn new(criterion: &'static str, message: impl Into<String>) -> Self {
Problem {
criterion,
message: message.into(),
}
}
#[must_use]
pub fn prefixed(mut self, prefix: &str) -> Self {
self.message = format!("{prefix}{}", self.message);
self
}
pub fn error(self, path: RepoPath, line: Option<usize>) -> Finding {
Finding::error(path, line, self.criterion, self.message)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn p(s: &str) -> RepoPath {
RepoPath::parse(s).unwrap()
}
#[test]
fn a_finding_carries_its_criterion_and_its_rule() {
let f = Finding::error(p("/a.md"), Some(3), "TLMD_109", "bad");
assert_eq!(
(f.criterion.as_str(), f.message.as_str()),
("TLMD_109", "bad")
);
assert_eq!(f.severity, Severity::Error);
let readme = Locator::schema("readme");
let f = Finding::warning(p("/a.md"), None, "TL_74", "bad").at(&readme);
assert_eq!(f.message, "bad (schema readme)");
assert_eq!(f.severity, Severity::Warning);
let f = Finding::error(p("/a.md"), None, "TL_74", "bad").at(&readme.at("section \"A\""));
assert_eq!(f.message, "bad (schema readme, section \"A\")");
}
#[test]
fn a_criterion_key_has_its_form() {
for key in [
"TL_1",
"TLMD_57",
"CLI-4_AC-5",
"CORE_P-2",
"SOKF_FORMAT_53",
] {
assert!(is_criterion(key), "{key}");
}
for key in ["unit", "TL", "TL_", "tl_1", "CLI-4", "CLI-x_AC-1", "_1"] {
assert!(!is_criterion(key), "{key}");
}
}
proptest::proptest! {
#[test]
fn a_locator_ends_the_message(message in "[ -~]{0,40}", schema in "[a-z]{1,8}") {
let f = Finding::error(p("/a.md"), None, "TL_74", message.clone())
.at(&Locator::schema(schema.clone()));
proptest::prop_assert_eq!(f.criterion.as_str(), "TL_74");
proptest::prop_assert!(f.message.starts_with(&message));
let suffix = format!("(schema {schema})");
proptest::prop_assert!(f.message.ends_with(&suffix));
}
}
#[test]
fn findings_round_trip_through_serde() {
let f = Finding::error(p("/a.md"), Some(3), "TL_53", "bad");
let json = serde_json::to_string(&f).unwrap();
let back: Finding = serde_json::from_str(&json).unwrap();
assert_eq!(back, f);
}
}