use crate::validate::{Report, ResultKind};
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub struct TextOptions {
pub include_reports: bool,
pub include_fired_rules: bool,
pub include_tests: bool,
pub include_diagnostics: bool,
pub label: Option<String>,
}
impl Default for TextOptions {
fn default() -> Self {
Self {
include_reports: true,
include_fired_rules: false,
include_tests: false,
include_diagnostics: true,
label: None,
}
}
}
impl TextOptions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn verbose() -> Self {
Self {
include_reports: true,
include_fired_rules: true,
include_tests: true,
include_diagnostics: true,
label: None,
}
}
#[must_use]
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
#[must_use]
pub fn with_tests(mut self, include: bool) -> Self {
self.include_tests = include;
self
}
#[must_use]
pub fn with_reports(mut self, include: bool) -> Self {
self.include_reports = include;
self
}
}
impl Report {
#[must_use]
pub fn to_text(&self) -> String {
self.to_text_with(&TextOptions::default())
}
#[must_use]
#[allow(clippy::too_many_lines)] pub fn to_text_with(&self, options: &TextOptions) -> String {
let mut out = String::new();
if let Some(label) = &options.label {
out.push_str(label);
out.push_str(":\n");
}
let mut failures = 0;
let mut reports = 0;
for pattern in &self.patterns {
for rule in &pattern.rules {
if options.include_fired_rules && rule.assertions.is_empty() {
out.push_str(&format!(
" fired {} (rule context {})\n",
rule.location, rule.context
));
}
for assertion in &rule.assertions {
match assertion.kind {
ResultKind::FailedAssert => failures += 1,
ResultKind::SuccessfulReport => {
reports += 1;
if !options.include_reports {
continue;
}
}
}
let label = assertion.flag.clone().unwrap_or_else(|| {
match assertion.kind {
ResultKind::FailedAssert => "error".to_string(),
ResultKind::SuccessfulReport => "report".to_string(),
}
});
out.push_str(&format!(" {label:<8} {}\n", assertion.location));
for line in assertion.text.lines() {
let line = line.trim();
if !line.is_empty() {
out.push_str(&format!(" {line}\n"));
}
}
if options.include_tests {
out.push_str(&format!(" test: {}\n", assertion.test));
out.push_str(&format!(" rule: {}\n", rule.context));
}
if options.include_diagnostics {
for diagnostic in &assertion.diagnostics {
for line in diagnostic.text.lines() {
let line = line.trim();
if !line.is_empty() {
out.push_str(&format!(" - {line}\n"));
}
}
}
}
if let Some(see) = &assertion.see {
out.push_str(&format!(" see: {see}\n"));
}
}
}
}
if failures == 0 && reports == 0 {
out.push_str(" no findings\n");
return out;
}
out.push_str(&format!(
" {} finding{}: {failures} failed assert{}, {reports} report{}\n",
failures + reports,
plural(failures + reports),
plural(failures),
plural(reports),
));
out
}
}
fn plural(count: usize) -> &'static str {
if count == 1 {
""
} else {
"s"
}
}
#[cfg(feature = "serde")]
impl Report {
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Document, Schema};
const SCHEMA: &str = r#"
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="line">
<assert test="@qty" flag="error">Needs a qty.</assert>
<report test="@free">Free line.</report>
</rule>
</pattern>
</schema>
"#;
fn text(document: &str, options: &TextOptions) -> String {
let schema = Schema::from_str(SCHEMA).unwrap();
let document = Document::from_str(document).unwrap();
schema.validate(&document).unwrap().to_text_with(options)
}
#[test]
fn a_clean_document_says_so() {
let output = text("<order><line qty='1'/></order>", &TextOptions::new());
assert!(output.contains("no findings"), "{output}");
}
#[test]
fn a_failure_shows_flag_location_and_message() {
let output = text("<order><line/></order>", &TextOptions::new());
assert!(output.contains("error"), "{output}");
assert!(output.contains("/order[1]/line[1]"), "{output}");
assert!(output.contains("Needs a qty."), "{output}");
}
#[test]
fn the_summary_counts_failures_and_reports_separately() {
let output = text("<order><line free='1'/></order>", &TextOptions::new());
assert!(output.contains("1 failed assert,"), "{output}");
assert!(output.contains("1 report"), "{output}");
}
#[test]
fn verbose_shows_the_test_and_the_rule() {
let output = text("<order><line/></order>", &TextOptions::verbose());
assert!(output.contains("test: @qty"), "{output}");
assert!(output.contains("rule: line"), "{output}");
}
#[test]
fn reports_can_be_suppressed() {
let options = TextOptions::new().with_reports(false);
let output = text("<order><line qty='1' free='1'/></order>", &options);
assert!(!output.contains("Free line."), "{output}");
}
#[test]
fn a_label_becomes_a_heading() {
let options = TextOptions::new().with_label("data.xml");
let output = text("<order><line/></order>", &options);
assert!(output.starts_with("data.xml:\n"), "{output}");
}
#[cfg(feature = "serde")]
#[test]
fn json_keeps_the_tree_structure() {
let schema = Schema::from_str(SCHEMA).unwrap();
let document = Document::from_str("<order><line/></order>").unwrap();
let json = schema.validate(&document).unwrap().to_json().unwrap();
assert!(json.contains("\"patterns\""), "{json}");
assert!(json.contains("\"FailedAssert\""), "{json}");
}
}