use std::fmt;
use serde::Serialize;
use super::report::Reported;
use crate::history::Target;
use crate::report::{Code, Diagnostic, Severity, Side};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Expectation {
pub code: Code,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<Severity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub side: Option<Side>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
}
impl Expectation {
#[must_use]
pub fn matches_reported(&self, reported: &Reported) -> bool {
match reported {
Reported::Contract(diagnostic) => self.commit.is_none() && self.matches(diagnostic),
Reported::History(diagnostic) => {
self.side.is_none()
&& self.code == diagnostic.code
&& self
.severity
.is_none_or(|severity| severity == diagnostic.severity)
&& self.line.is_none_or(|line| Some(line) == diagnostic.line)
&& self
.rule
.as_deref()
.is_none_or(|rule| Some(rule) == diagnostic.rule.as_deref())
&& self
.message
.as_deref()
.is_none_or(|message| message == diagnostic.message)
&& self.path.as_deref().is_none_or(|path| {
matches!(&diagnostic.target, Target::Path { path: actual } if actual == path)
})
&& self.commit.as_deref().is_none_or(|commit| {
matches!(&diagnostic.target, Target::Commit { commit: actual } if actual == commit)
})
}
}
}
#[must_use]
pub fn matches(&self, diagnostic: &Diagnostic) -> bool {
self.code == diagnostic.code
&& self
.severity
.is_none_or(|severity| severity == diagnostic.severity)
&& self
.path
.as_deref()
.is_none_or(|path| path == diagnostic.path)
&& self.line.is_none_or(|line| Some(line) == diagnostic.line)
&& self.side.is_none_or(|side| side == diagnostic.side)
&& self
.rule
.as_deref()
.is_none_or(|rule| Some(rule) == diagnostic.rule.as_deref())
&& self
.message
.as_deref()
.is_none_or(|message| message == diagnostic.message)
}
}
impl fmt::Display for Expectation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.code)?;
if let Some(severity) = self.severity {
let text = match severity {
Severity::Error => "error",
Severity::Warning => "warning",
};
write!(f, " severity={text}")?;
}
if let Some(side) = self.side {
write!(f, " side={side}")?;
}
if let Some(commit) = &self.commit {
write!(f, " commit={commit}")?;
}
if let Some(path) = &self.path {
write!(f, " path={path}")?;
}
if let Some(line) = self.line {
write!(f, " line={line}")?;
}
if let Some(rule) = &self.rule {
write!(f, " rule={rule}")?;
}
if let Some(message) = &self.message {
write!(f, " message={message:?}")?;
}
Ok(())
}
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Assignment {
pub missing: Vec<usize>,
pub unexpected: Vec<usize>,
}
#[must_use]
pub fn assign(expectations: &[Expectation], diagnostics: &[Reported]) -> Assignment {
let mut owner: Vec<Option<usize>> = vec![None; diagnostics.len()];
let mut matched_expectation = vec![false; expectations.len()];
for (index, expectation) in expectations.iter().enumerate() {
let mut visited = vec![false; diagnostics.len()];
if augment(
index,
expectation,
expectations,
diagnostics,
&mut owner,
&mut visited,
) {
matched_expectation[index] = true;
}
}
let mut holds = vec![false; expectations.len()];
for holder in owner.iter().flatten() {
holds[*holder] = true;
}
Assignment {
missing: (0..expectations.len())
.filter(|index| !holds[*index])
.collect(),
unexpected: (0..diagnostics.len())
.filter(|index| owner[*index].is_none())
.collect(),
}
}
fn augment(
index: usize,
expectation: &Expectation,
expectations: &[Expectation],
diagnostics: &[Reported],
owner: &mut [Option<usize>],
visited: &mut [bool],
) -> bool {
for (position, diagnostic) in diagnostics.iter().enumerate() {
if visited[position] || !expectation.matches_reported(diagnostic) {
continue;
}
visited[position] = true;
let free = match owner[position] {
None => true,
Some(holder) => augment(
holder,
&expectations[holder],
expectations,
diagnostics,
owner,
visited,
),
};
if free {
owner[position] = Some(index);
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn expect(code: Code, path: Option<&str>) -> Expectation {
Expectation {
code,
severity: None,
path: path.map(str::to_owned),
line: None,
side: None,
rule: None,
message: None,
commit: None,
}
}
fn diagnostic(code: Code, path: &str) -> Reported {
Reported::Contract(Diagnostic::new(code, path, "m"))
}
#[test]
fn matching_is_a_multiset_assignment() {
let diagnostics = [
diagnostic(Code::PolicyError, "a.md"),
diagnostic(Code::PolicyError, "b.md"),
];
let assignment = assign(
&[
expect(Code::PolicyError, None),
expect(Code::PolicyError, None),
],
&diagnostics[..1],
);
assert_eq!(assignment.missing, [1]);
assert!(assignment.unexpected.is_empty());
let assignment = assign(
&[
expect(Code::PolicyError, None),
expect(Code::PolicyError, Some("a.md")),
],
&diagnostics,
);
assert_eq!(assignment, Assignment::default());
let assignment = assign(&[expect(Code::PolicyError, Some("b.md"))], &diagnostics);
assert!(assignment.missing.is_empty());
assert_eq!(assignment.unexpected, [0]);
assert_eq!(assign(&[], &[]), Assignment::default());
assert_eq!(
assign(&[expect(Code::Envelope, None)], &diagnostics),
Assignment {
missing: vec![0],
unexpected: vec![0, 1],
}
);
}
#[test]
fn every_asserted_field_must_agree() {
let actual = Diagnostic::new(Code::PolicyError, "a.md", "text")
.at_line(Some(3))
.with_rule(Some("r".to_owned()))
.on_side(Side::Baseline);
let full = Expectation {
code: Code::PolicyError,
severity: Some(Severity::Error),
path: Some("a.md".to_owned()),
line: Some(3),
side: Some(Side::Baseline),
rule: Some("r".to_owned()),
message: Some("text".to_owned()),
commit: None,
};
assert!(full.matches(&actual));
assert_eq!(
full.to_string(),
"B015 severity=error side=baseline path=a.md line=3 rule=r message=\"text\""
);
let variants = [
Expectation {
code: Code::PolicyWarning,
..full.clone()
},
Expectation {
severity: Some(Severity::Warning),
..full.clone()
},
Expectation {
path: Some("b.md".to_owned()),
..full.clone()
},
Expectation {
line: Some(4),
..full.clone()
},
Expectation {
side: Some(Side::Candidate),
..full.clone()
},
Expectation {
rule: Some("s".to_owned()),
..full.clone()
},
Expectation {
message: Some("other".to_owned()),
..full.clone()
},
];
for variant in variants {
assert!(!variant.matches(&actual), "{variant}");
}
assert!(expect(Code::PolicyError, None).matches(&actual));
let without_line = Expectation {
line: None,
..full.clone()
};
assert!(without_line.matches(&actual));
assert!(!full.matches(&Diagnostic::new(Code::PolicyError, "a.md", "text")));
let commit = Expectation {
commit: Some("pending".to_owned()),
side: None,
path: None,
..full.clone()
};
assert!(!commit.matches_reported(&Reported::Contract(actual.clone())));
}
#[test]
fn history_diagnostics_match_by_commit_or_path_target() {
use crate::history::HistoryDiagnostic;
let pending = Reported::History(
HistoryDiagnostic::new(
Target::Commit {
commit: "pending".to_owned(),
},
Code::HistoryError,
"bad header",
)
.at_line(Some(1))
.with_rule(Some("commit-policy".to_owned())),
);
let range = Reported::History(HistoryDiagnostic::new(
Target::Range {},
Code::HistoryWarning,
"note",
));
let script = Reported::History(HistoryDiagnostic::new(
Target::Path {
path: "bearout.star".to_owned(),
},
Code::ScriptFailure,
"boom",
));
let base = Expectation {
code: Code::HistoryError,
severity: None,
path: None,
line: None,
side: None,
rule: None,
message: None,
commit: None,
};
let on_pending = Expectation {
commit: Some("pending".to_owned()),
line: Some(1),
rule: Some("commit-policy".to_owned()),
..base.clone()
};
assert!(on_pending.matches_reported(&pending));
assert!(
base.matches_reported(&pending),
"an unasserted target is free"
);
assert!(
!Expectation {
commit: Some("0000".to_owned()),
..base.clone()
}
.matches_reported(&pending)
);
assert!(
!Expectation {
line: Some(2),
..base.clone()
}
.matches_reported(&pending)
);
assert!(
!Expectation {
side: Some(Side::Candidate),
..base.clone()
}
.matches_reported(&pending),
"a side never matches history"
);
assert!(
Expectation {
code: Code::HistoryWarning,
..base.clone()
}
.matches_reported(&range)
);
assert!(
!Expectation {
code: Code::HistoryWarning,
commit: Some("pending".to_owned()),
..base.clone()
}
.matches_reported(&range)
);
assert!(
Expectation {
code: Code::ScriptFailure,
path: Some("bearout.star".to_owned()),
..base.clone()
}
.matches_reported(&script)
);
assert_eq!(
on_pending.to_string(),
"B032 commit=pending line=1 rule=commit-policy"
);
assert_eq!(
pending.to_string(),
"commit pending:1:B032[commit-policy]: bad header"
);
assert_eq!(pending.commit(), Some("pending"));
assert_eq!(script.path(), Some("bearout.star"));
assert_eq!(range.code(), Code::HistoryWarning);
}
}