#[path = "report.rs"]
mod report;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use quick_xml::events::Event;
use quick_xml::Reader;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TestOutcome {
Pass,
Fail,
Skip,
Error,
}
#[derive(Debug, Clone)]
pub struct TestResult {
pub name: String,
pub group: String,
pub expected: ExpectedOutcome,
pub actual: TestOutcome,
pub duration: Duration,
pub error_message: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExpectedOutcome {
Valid,
Invalid,
NotKnown,
RuntimeSchemaError,
ImplementationDefined,
ImplementationDependent,
Indeterminate,
InstanceValid,
InstanceInvalid,
InstanceIndeterminate,
InstanceImplementationDefined,
InstanceImplementationDependent,
InstanceRuntimeSchemaError,
InstanceNotKnown,
}
#[derive(Debug, Clone, Default)]
pub struct TestStats {
pub passed: usize,
pub failed: usize,
pub skipped: usize,
pub errors: usize,
pub total_duration: Duration,
}
impl TestStats {
pub fn total(&self) -> usize {
self.passed + self.failed + self.skipped + self.errors
}
pub fn pass_rate(&self) -> f64 {
let total = self.passed + self.failed;
if total == 0 {
0.0
} else {
self.passed as f64 / total as f64
}
}
pub fn add_result(&mut self, result: &TestResult) {
match result.actual {
TestOutcome::Pass => self.passed += 1,
TestOutcome::Fail => self.failed += 1,
TestOutcome::Skip => self.skipped += 1,
TestOutcome::Error => self.errors += 1,
}
self.total_duration += result.duration;
}
}
#[derive(Debug, Clone)]
pub struct VersionedExpected {
pub version: Option<String>,
pub outcome: ExpectedOutcome,
}
const XSD_VERSIONS: &[&str] = &["1.0", "1.1"];
const ACTIVE_CTA_PROFILE: &str = "full-xpath-in-CTA";
const UNSUPPORTED_CTA_PROFILES: &[&str] = &["restricted-xpath-in-CTA"];
fn parse_expected_outcome(
validity: &str,
in_instance_test: bool,
) -> Result<ExpectedOutcome, String> {
let outcome = match (in_instance_test, validity) {
(false, "valid") => ExpectedOutcome::Valid,
(false, "invalid") => ExpectedOutcome::Invalid,
(false, "notKnown") => ExpectedOutcome::NotKnown,
(false, "runtime-schema-error") => ExpectedOutcome::RuntimeSchemaError,
(false, "implementation-defined") => ExpectedOutcome::ImplementationDefined,
(false, "implementation-dependent") => ExpectedOutcome::ImplementationDependent,
(false, "indeterminate") => ExpectedOutcome::Indeterminate,
(true, "valid") => ExpectedOutcome::InstanceValid,
(true, "invalid") => ExpectedOutcome::InstanceInvalid,
(true, "notKnown") => ExpectedOutcome::InstanceNotKnown,
(true, "runtime-schema-error") => ExpectedOutcome::InstanceRuntimeSchemaError,
(true, "implementation-defined") => ExpectedOutcome::InstanceImplementationDefined,
(true, "implementation-dependent") => ExpectedOutcome::InstanceImplementationDependent,
(true, "indeterminate") => ExpectedOutcome::InstanceIndeterminate,
(_, other) => {
return Err(format!("Unsupported expected validity '{}'", other));
}
};
Ok(outcome)
}
fn is_non_asserting_expected(outcome: ExpectedOutcome) -> bool {
matches!(
outcome,
ExpectedOutcome::Indeterminate
| ExpectedOutcome::ImplementationDefined
| ExpectedOutcome::ImplementationDependent
| ExpectedOutcome::InstanceIndeterminate
| ExpectedOutcome::InstanceImplementationDefined
| ExpectedOutcome::InstanceImplementationDependent
)
}
fn expected_skip_reason(outcome: ExpectedOutcome) -> Option<&'static str> {
match outcome {
ExpectedOutcome::Indeterminate | ExpectedOutcome::InstanceIndeterminate => Some(
"W3C expected outcome is 'indeterminate'; the driver skips non-asserting cases",
),
ExpectedOutcome::ImplementationDefined
| ExpectedOutcome::InstanceImplementationDefined => Some(
"W3C expected outcome is 'implementation-defined'; this driver does not model implementation profiles",
),
ExpectedOutcome::ImplementationDependent
| ExpectedOutcome::InstanceImplementationDependent => Some(
"W3C expected outcome is 'implementation-dependent'; this driver does not model implementation profiles",
),
ExpectedOutcome::RuntimeSchemaError => Some(
"W3C expected outcome 'runtime-schema-error' is meaningless for schema tests",
),
ExpectedOutcome::InstanceRuntimeSchemaError => Some(
"W3C expected outcome 'runtime-schema-error' is not modeled separately by this driver",
),
_ => None,
}
}
fn is_xsd_version(v: &str) -> bool {
XSD_VERSIONS.contains(&v)
}
fn extract_xsd_versions(version_attr: &str) -> Vec<&str> {
version_attr
.split_whitespace()
.filter(|tok| is_xsd_version(tok))
.collect()
}
fn is_xml_wellformedness_error(msg: &str) -> bool {
msg.starts_with("XML parse error:")
|| msg.starts_with("Attribute error:")
|| msg.starts_with("Attribute unescape error:")
|| msg.starts_with("Text unescape error:")
|| msg.starts_with("CData UTF-8 error:")
}
#[derive(Debug, Clone)]
pub struct TestCase {
pub name: String,
pub schema_files: Vec<PathBuf>,
pub instance_file: Option<PathBuf>,
pub expected: ExpectedOutcome,
pub expected_versions: Vec<VersionedExpected>,
pub group: String,
pub version: String,
pub version_label: String,
pub description: Option<String>,
}
pub struct TestSuiteParser {
base_path: PathBuf,
}
impl TestSuiteParser {
pub fn new(base_path: PathBuf) -> Self {
Self { base_path }
}
pub fn parse_manifest(&self) -> Result<Vec<TestCase>, String> {
let manifest_path = self.base_path.join("suite.xml");
if !manifest_path.exists() {
let alt_path = self.base_path.join("testSuite.xml");
if alt_path.exists() {
return self.parse_xml_manifest(&alt_path, None);
}
return Err(format!(
"Test suite manifest not found at {:?}",
manifest_path
));
}
self.parse_xml_manifest(&manifest_path, None)
}
fn parse_xml_manifest(
&self,
path: &Path,
inherited_version: Option<&str>,
) -> Result<Vec<TestCase>, String> {
let raw =
fs::read(path).map_err(|e| format!("Failed to read manifest {:?}: {}", path, e))?;
let content = xsd_schema::decode_xml_bytes(raw)
.map_err(|e| format!("Failed to decode manifest {:?}: {}", path, e))?;
let manifest_dir = path
.parent()
.ok_or_else(|| format!("Cannot determine parent directory of {:?}", path))?;
let mut reader = Reader::from_str(&content);
reader.trim_text(true);
let mut tests = Vec::new();
let mut buf = Vec::new();
let mut current_group = String::new();
let mut current_test: Option<TestCase> = None;
let mut in_instance_test = false;
let mut group_schema_files: Vec<PathBuf> = Vec::new();
let mut testset_version_attr: Option<String> = inherited_version.map(|s| s.to_string());
let mut group_version_attr: Option<String> = None;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
let name_ref = e.local_name();
let local_name = String::from_utf8_lossy(name_ref.as_ref()).to_string();
match local_name.as_str() {
"testSuite" | "testSet" => {
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"version" {
testset_version_attr =
Some(String::from_utf8_lossy(&attr.value).to_string());
}
}
}
"testSetRef" => {
for attr in e.attributes().flatten() {
let key = attr.key.as_ref();
if key == b"xlink:href" || key == b"href" {
let href = String::from_utf8_lossy(&attr.value);
let ref_path = manifest_dir.join(href.as_ref());
if ref_path.exists() {
let ver = testset_version_attr.as_deref();
match self.parse_xml_manifest(&ref_path, ver) {
Ok(ref_tests) => tests.extend(ref_tests),
Err(e) => {
eprintln!(
"Warning: Failed to parse referenced testSet {:?}: {}",
ref_path, e
);
}
}
} else {
eprintln!(
"Warning: Referenced testSet not found: {:?}",
ref_path
);
}
}
}
}
"testGroup" => {
group_version_attr = None;
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"name" => {
current_group =
String::from_utf8_lossy(&attr.value).to_string();
}
b"version" => {
group_version_attr =
Some(String::from_utf8_lossy(&attr.value).to_string());
}
_ => {}
}
}
group_schema_files.clear();
}
"schemaTest" | "instanceTest" => {
let is_instance = local_name == "instanceTest";
let mut test_version_attr: Option<String> = None;
let mut test_name = String::new();
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"name" => {
test_name =
String::from_utf8_lossy(&attr.value).to_string();
}
b"version" => {
test_version_attr =
Some(String::from_utf8_lossy(&attr.value).to_string());
}
_ => {}
}
}
let raw_label = test_version_attr
.as_deref()
.or(group_version_attr.as_deref())
.or(testset_version_attr.as_deref())
.unwrap_or("1.0")
.to_string();
let xsd_versions = Self::resolve_xsd_versions(
test_version_attr.as_deref(),
group_version_attr.as_deref(),
testset_version_attr.as_deref(),
);
let base_test = TestCase {
name: test_name,
schema_files: if is_instance {
group_schema_files.clone()
} else {
Vec::new()
},
instance_file: None,
expected: if is_instance {
ExpectedOutcome::InstanceValid
} else {
ExpectedOutcome::Valid
},
expected_versions: Vec::new(),
group: current_group.clone(),
version: xsd_versions[0].to_string(),
version_label: raw_label,
description: None,
};
in_instance_test = is_instance;
current_test = Some(base_test);
}
"schemaDocument" => {
if let Some(ref mut test) = current_test {
for attr in e.attributes().flatten() {
let key = attr.key.as_ref();
if key == b"xlink:href" || key == b"href" {
let href = String::from_utf8_lossy(&attr.value);
let schema_path = manifest_dir.join(href.as_ref());
test.schema_files.push(schema_path);
}
}
}
}
"instanceDocument" => {
if let Some(ref mut test) = current_test {
for attr in e.attributes().flatten() {
let key = attr.key.as_ref();
if key == b"xlink:href" || key == b"href" {
let href = String::from_utf8_lossy(&attr.value);
let instance_path = manifest_dir.join(href.as_ref());
test.instance_file = Some(instance_path);
}
}
}
}
"expected" => {
if let Some(ref mut test) = current_test {
let mut validity_str = None;
let mut expected_version = None;
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"validity" => {
validity_str = Some(
String::from_utf8_lossy(&attr.value).to_string(),
);
}
b"version" => {
expected_version = Some(
String::from_utf8_lossy(&attr.value).to_string(),
);
}
_ => {}
}
}
if let Some(validity) = validity_str {
let outcome =
parse_expected_outcome(&validity, in_instance_test)
.map_err(|e| {
format!(
"Invalid expected outcome '{}' in {:?}: {}",
validity, path, e
)
})?;
test.expected_versions.push(VersionedExpected {
version: expected_version,
outcome,
});
}
}
}
_ => {}
}
}
Ok(Event::End(ref e)) => {
let name_ref = e.local_name();
let local_name = String::from_utf8_lossy(name_ref.as_ref()).to_string();
match local_name.as_str() {
"testGroup" => {
current_group = String::new();
group_schema_files.clear();
group_version_attr = None;
}
"schemaTest" => {
if let Some(test) = current_test.take() {
if !test.schema_files.is_empty() {
group_schema_files = test.schema_files.clone();
expand_test_versions(test, &mut tests);
}
}
in_instance_test = false;
}
"instanceTest" => {
if let Some(test) = current_test.take() {
if test.instance_file.is_some() {
expand_test_versions(test, &mut tests);
}
}
in_instance_test = false;
}
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => {
return Err(format!("XML parse error in {:?}: {}", path, e));
}
_ => {}
}
buf.clear();
}
Ok(tests)
}
fn resolve_xsd_versions<'a>(
test_attr: Option<&'a str>,
group_attr: Option<&'a str>,
testset_attr: Option<&'a str>,
) -> Vec<&'a str> {
for attr in [test_attr, group_attr, testset_attr].into_iter().flatten() {
let versions = extract_xsd_versions(attr);
if !versions.is_empty() {
return versions;
}
}
vec!["1.0"]
}
#[allow(dead_code)]
pub fn scan_for_tests(&self) -> Result<Vec<TestCase>, String> {
let mut tests = Vec::new();
self.scan_directory(&self.base_path, &mut tests)?;
Ok(tests)
}
#[allow(clippy::only_used_in_recursion)]
fn scan_directory(&self, dir: &Path, tests: &mut Vec<TestCase>) -> Result<(), String> {
let entries =
fs::read_dir(dir).map_err(|e| format!("Failed to read directory {:?}: {}", dir, e))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
self.scan_directory(&path, tests)?;
} else if path.extension().is_some_and(|e| e == "xsd") {
let name = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let group = path
.parent()
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "default".to_string());
tests.push(TestCase {
name,
schema_files: vec![path],
instance_file: None,
expected: ExpectedOutcome::Valid, expected_versions: Vec::new(),
group,
version: "1.0".to_string(),
version_label: "1.0".to_string(),
description: None,
});
}
}
Ok(())
}
}
struct ExpandedVariant {
xsd_version: String,
profile_label: Option<String>,
}
fn expand_test_versions(test: TestCase, out: &mut Vec<TestCase>) {
let version_label = &test.version_label;
let mut xsd_versions: Vec<String> = extract_xsd_versions(version_label)
.into_iter()
.map(|s| s.to_string())
.collect();
if xsd_versions.is_empty() {
xsd_versions.push(test.version.clone());
}
let mut profile_labels: Vec<String> = Vec::new();
for ve in &test.expected_versions {
if let Some(ref v) = ve.version {
if is_xsd_version(v) {
if !xsd_versions.contains(v) {
xsd_versions.push(v.clone());
}
} else {
if !profile_labels.contains(v) {
profile_labels.push(v.clone());
}
}
}
}
let mut variants: Vec<ExpandedVariant> = Vec::new();
if profile_labels.is_empty() {
for ver in &xsd_versions {
variants.push(ExpandedVariant {
xsd_version: ver.clone(),
profile_label: None,
});
}
} else {
let supported_label = profile_labels
.iter()
.find(|l| l.as_str() == ACTIVE_CTA_PROFILE)
.cloned()
.or_else(|| {
profile_labels
.iter()
.find(|l| !UNSUPPORTED_CTA_PROFILES.contains(&l.as_str()))
.cloned()
});
if let Some(label) = supported_label {
for ver in &xsd_versions {
variants.push(ExpandedVariant {
xsd_version: ver.clone(),
profile_label: Some(label.clone()),
});
}
}
}
variants.sort_by(|a, b| {
a.xsd_version
.cmp(&b.xsd_version)
.then_with(|| a.profile_label.cmp(&b.profile_label))
});
for variant in &variants {
let mut copy = test.clone();
copy.version = variant.xsd_version.clone();
if let Some(ref label) = variant.profile_label {
copy.version_label = label.clone();
}
resolve_expected_for_version(&mut copy);
out.push(copy);
}
}
fn resolve_expected_for_version(test: &mut TestCase) {
if test.expected_versions.is_empty() {
return;
}
if let Some(ve) = test
.expected_versions
.iter()
.find(|ve| ve.version.as_deref() == Some(test.version.as_str()))
{
test.expected = ve.outcome;
return;
}
if test.version_label != test.version {
if let Some(ve) = test
.expected_versions
.iter()
.find(|ve| ve.version.as_deref() == Some(test.version_label.as_str()))
{
test.expected = ve.outcome;
return;
}
}
if let Some(ve) = test
.expected_versions
.iter()
.find(|ve| ve.version.is_none())
{
test.expected = ve.outcome;
return;
}
test.expected = test.expected_versions[0].outcome;
}
pub struct TestRunner {
tests: Vec<TestCase>,
group_filter: Option<String>,
version_filter: Option<String>,
name_filters: Vec<String>,
max_tests: usize,
verbose: bool,
}
impl TestRunner {
pub fn new(tests: Vec<TestCase>) -> Self {
Self {
tests,
group_filter: None,
version_filter: None,
name_filters: Vec::new(),
max_tests: 0,
verbose: false,
}
}
pub fn with_group_filter(mut self, group: Option<String>) -> Self {
self.group_filter = group;
self
}
pub fn with_version_filter(mut self, version: Option<String>) -> Self {
self.version_filter = version;
self
}
pub fn with_name_filters(mut self, names: Vec<String>) -> Self {
self.name_filters = names;
self
}
pub fn with_max_tests(mut self, max: usize) -> Self {
self.max_tests = max;
self
}
pub fn with_verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
pub fn run(&self) -> (Vec<TestResult>, HashMap<String, TestStats>) {
let mut results = Vec::new();
let mut stats_by_group: HashMap<String, TestStats> = HashMap::new();
let tests: Vec<_> = self
.tests
.iter()
.filter(|t| {
if let Some(ref group) = self.group_filter {
if !t.group.contains(group) {
return false;
}
}
if let Some(ref version) = self.version_filter {
if is_xsd_version(version) {
if t.version != *version {
return false;
}
} else {
if !t
.version_label
.split_whitespace()
.any(|tok| tok == version.as_str())
{
return false;
}
}
}
if !self.name_filters.is_empty()
&& !self
.name_filters
.iter()
.any(|n| t.name.contains(n.as_str()))
{
return false;
}
true
})
.take(if self.max_tests > 0 {
self.max_tests
} else {
usize::MAX
})
.collect();
println!("Running {} tests...", tests.len());
for test in tests {
let result = self.run_test(test);
let stats = stats_by_group.entry(result.group.clone()).or_default();
stats.add_result(&result);
if self.verbose {
let status = match result.actual {
TestOutcome::Pass => "PASS",
TestOutcome::Fail => "FAIL",
TestOutcome::Skip => "SKIP",
TestOutcome::Error => "ERROR",
};
println!(" {} - {} ({:?})", status, result.name, result.duration);
if let Some(ref msg) = result.error_message {
println!(" Error: {}", msg);
}
}
results.push(result);
}
(results, stats_by_group)
}
fn run_test(&self, test: &TestCase) -> TestResult {
let start = Instant::now();
for schema_file in &test.schema_files {
if !schema_file.exists() {
return TestResult {
name: test.name.clone(),
group: test.group.clone(),
expected: test.expected,
actual: TestOutcome::Skip,
duration: start.elapsed(),
error_message: Some(format!("Schema file not found: {:?}", schema_file)),
};
}
}
let mut builder = make_schema_builder(&test.version);
let mut parse_error: Option<String> = None;
for schema_file in &test.schema_files {
let path = match schema_file.canonicalize() {
Ok(abs) => abs.to_string_lossy().into_owned(),
Err(e) => {
parse_error = Some(format!("Failed to resolve path {:?}: {}", schema_file, e));
break;
}
};
if let Err(e) = builder.try_add(&path) {
parse_error = Some(e.to_string());
break;
}
}
let schema_set = if parse_error.is_none() {
match builder.compile() {
Ok(compiled) => compiled.into_schema_set(),
Err(e) => {
parse_error = Some(e.to_string());
xsd_schema::SchemaSet::new()
}
}
} else {
xsd_schema::SchemaSet::new()
};
let duration = start.elapsed();
let (actual, error_message) = match (test.expected, &parse_error) {
(expected, _) if is_non_asserting_expected(expected) => (
TestOutcome::Skip,
expected_skip_reason(expected).map(str::to_string),
),
(ExpectedOutcome::Valid, None) => (TestOutcome::Pass, None),
(ExpectedOutcome::Valid, Some(e)) => {
if test.version == "1.0"
&& e.contains("requires XSD 1.1 but schema is in XSD 1.0")
{
(
TestOutcome::Skip,
Some(
"Skipped: schema uses XSD 1.1 features unavailable in 1.0 mode"
.to_string(),
),
)
} else {
(TestOutcome::Fail, Some(e.clone()))
}
}
(ExpectedOutcome::Invalid, None) => (
TestOutcome::Fail,
Some("Schema was valid but expected invalid".to_string()),
),
(ExpectedOutcome::Invalid, Some(_)) => (TestOutcome::Pass, None),
(ExpectedOutcome::NotKnown, _) => (
TestOutcome::Skip,
Some("W3C expected outcome 'notKnown' is meaningless for schema tests".to_string()),
),
(ExpectedOutcome::RuntimeSchemaError, _) => (
TestOutcome::Skip,
Some(
"W3C expected outcome 'runtime-schema-error' is meaningless for schema tests"
.to_string(),
),
),
(ExpectedOutcome::InstanceRuntimeSchemaError, _) => (
TestOutcome::Skip,
expected_skip_reason(ExpectedOutcome::InstanceRuntimeSchemaError)
.map(str::to_string),
),
(
ExpectedOutcome::InstanceValid
| ExpectedOutcome::InstanceInvalid
| ExpectedOutcome::InstanceNotKnown,
_,
) => {
if let Some(ref e) = parse_error {
if test.version == "1.0"
&& e.contains("requires XSD 1.1 but schema is in XSD 1.0")
{
return TestResult {
name: test.name.clone(),
group: test.group.clone(),
expected: test.expected,
actual: TestOutcome::Skip,
duration: start.elapsed(),
error_message: Some(
"Skipped: schema uses XSD 1.1 features unavailable in 1.0 mode"
.to_string(),
),
};
}
if test.expected == ExpectedOutcome::InstanceInvalid {
return TestResult {
name: test.name.clone(),
group: test.group.clone(),
expected: test.expected,
actual: TestOutcome::Pass,
duration: start.elapsed(),
error_message: None,
};
}
return TestResult {
name: test.name.clone(),
group: test.group.clone(),
expected: test.expected,
actual: TestOutcome::Error,
duration: start.elapsed(),
error_message: Some(format!("Schema compilation failed: {}", e)),
};
}
let instance_file = match &test.instance_file {
Some(f) if f.exists() => f,
Some(f) => {
return TestResult {
name: test.name.clone(),
group: test.group.clone(),
expected: test.expected,
actual: TestOutcome::Skip,
duration: start.elapsed(),
error_message: Some(format!("Instance file not found: {:?}", f)),
};
}
None => {
return TestResult {
name: test.name.clone(),
group: test.group.clone(),
expected: test.expected,
actual: TestOutcome::Skip,
duration: start.elapsed(),
error_message: Some("No instance file specified".to_string()),
};
}
};
match validate_instance(&schema_set, instance_file) {
Ok((actual_outcome, error_msgs)) => match (test.expected, actual_outcome) {
(ExpectedOutcome::InstanceValid, InstanceActualOutcome::Valid) => {
(TestOutcome::Pass, None)
}
(ExpectedOutcome::InstanceValid, _) => {
let detail = if error_msgs.is_empty() {
format!("Instance was {:?} but expected valid", actual_outcome)
} else {
format!(
"Instance was {:?} but expected valid: {}",
actual_outcome,
error_msgs.join("; ")
)
};
(TestOutcome::Fail, Some(detail))
}
(ExpectedOutcome::InstanceInvalid, InstanceActualOutcome::Invalid) => {
(TestOutcome::Pass, None)
}
(ExpectedOutcome::InstanceInvalid, _) => (
TestOutcome::Fail,
Some(format!(
"Instance was {:?} but expected invalid",
actual_outcome
)),
),
(ExpectedOutcome::InstanceNotKnown, InstanceActualOutcome::NotKnown) => {
(TestOutcome::Pass, None)
}
(ExpectedOutcome::InstanceNotKnown, _) => (
TestOutcome::Fail,
Some(format!(
"Instance was {:?} but expected notKnown",
actual_outcome
)),
),
_ => unreachable!(),
},
Err(e) => {
if test.expected == ExpectedOutcome::InstanceInvalid
&& is_xml_wellformedness_error(&e)
{
(TestOutcome::Pass, None)
} else {
(TestOutcome::Error, Some(e))
}
}
}
}
_ => unreachable!("non-asserting expected outcomes are handled before execution"),
};
TestResult {
name: test.name.clone(),
group: test.group.clone(),
expected: test.expected,
actual,
duration,
error_message,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InstanceActualOutcome {
Valid,
Invalid,
NotKnown,
}
fn validate_instance(
schema_set: &xsd_schema::SchemaSet,
instance_path: &Path,
) -> Result<(InstanceActualOutcome, Vec<String>), String> {
let raw = fs::read(instance_path).map_err(|e| format!("Failed to read instance: {}", e))?;
let content = xsd_schema::decode_xml_to_utf8_bytes(raw)
.map_err(|e| format!("Failed to decode instance: {}", e))?;
let canonical_path = instance_path
.canonicalize()
.unwrap_or_else(|_| instance_path.to_path_buf());
let (actual_outcome, error_msgs, sl_hints, nnsl_hints) =
validate_instance_pass(schema_set, &canonical_path, &content)?;
let outcome = xsd_schema::enrich_schema_set(schema_set, &sl_hints, &nnsl_hints);
if let Some(enriched) = outcome.schema_set {
let (outcome2, errors2, _, _) =
validate_instance_pass(&enriched, &canonical_path, &content)?;
return Ok((outcome2, errors2));
}
Ok((actual_outcome, error_msgs))
}
type ValidationPassResult = Result<
(
InstanceActualOutcome,
Vec<String>,
Vec<xsd_schema::validation::info::SchemaLocationHint>,
Vec<xsd_schema::validation::info::NoNamespaceSchemaLocationHint>,
),
String,
>;
fn scan_unparsed_entities(xml: &str) -> std::collections::HashSet<String> {
let mut entities = std::collections::HashSet::new();
let mut search_from = 0;
while let Some(start) = xml[search_from..].find("<!ENTITY") {
let abs_start = search_from + start;
let rest = &xml[abs_start + 8..]; let end = match rest.find('>') {
Some(e) => e,
None => break,
};
let decl = &rest[..end];
if decl.contains("NDATA") {
let trimmed = decl.trim_start();
if !trimmed.starts_with('%') {
if let Some(name_end) = trimmed.find(|c: char| c.is_whitespace()) {
let name = &trimmed[..name_end];
entities.insert(name.to_string());
}
}
}
search_from = abs_start + 8 + end + 1;
}
entities
}
#[allow(clippy::type_complexity)]
fn validate_instance_pass(
schema_set: &xsd_schema::SchemaSet,
instance_path: &Path,
content: &[u8],
) -> ValidationPassResult {
use xsd_schema::validation::{
drive_quick_xml_with, DriveWithError, EndElementInfo, SchemaValidity,
ValidationEventHandler,
};
let flags = xsd_schema::validation::ValidationFlags::default()
| xsd_schema::validation::ValidationFlags::PROCESS_IDENTITY_CONSTRAINTS;
#[cfg(feature = "xsd11")]
let validator = xsd_schema::validation::SchemaValidator::new_fragment_buffer(schema_set, flags);
#[cfg(not(feature = "xsd11"))]
let validator = xsd_schema::validation::SchemaValidator::new(schema_set, flags);
let mut errors = Vec::new();
let mut warnings = Vec::new();
let sink = xsd_schema::validation::CollectingValidationSink {
errors: &mut errors,
warnings: &mut warnings,
};
let mut runtime = validator.start_run(sink);
let base_uri = instance_path.to_string_lossy();
runtime.set_instance_base_uri(base_uri.as_ref());
{
let xml_str = std::str::from_utf8(content).unwrap_or("");
let has_external_only_dtd = if let Some(dt_start) = xml_str.find("<!DOCTYPE") {
let dt_rest = &xml_str[dt_start..];
let dt_end = dt_rest.find('>').unwrap_or(dt_rest.len());
let dt_decl = &dt_rest[..dt_end];
(dt_decl.contains("SYSTEM") || dt_decl.contains("PUBLIC")) && !dt_decl.contains('[')
} else {
false
};
if !has_external_only_dtd {
let unparsed = scan_unparsed_entities(xml_str);
runtime.set_unparsed_entities(unparsed);
}
}
struct DriverHandler {
root_validity: Option<SchemaValidity>,
}
impl ValidationEventHandler for DriverHandler {
type Error = std::convert::Infallible;
fn after_end_element(
&mut self,
info: &EndElementInfo,
depth: usize,
) -> Result<(), Self::Error> {
if depth == 1 {
self.root_validity = Some(info.validity);
}
Ok(())
}
}
let mut handler = DriverHandler { root_validity: None };
drive_quick_xml_with(content, &mut runtime, schema_set, &mut handler).map_err(|e| match e {
DriveWithError::Parse(e) => format!("XML parse error: {}", e),
DriveWithError::Utf8(e) => format!("UTF-8 error: {}", e),
DriveWithError::UnboundPrefix(p) => format!("Unbound prefix: {}", p),
DriveWithError::UnexpectedEof { depth } => {
format!("Unexpected EOF: {} element(s) still open", depth)
}
DriveWithError::Hook(_) => unreachable!("DriverHandler is infallible"),
})?;
let sl_hints = runtime.schema_location_hints().to_vec();
let nnsl_hints = runtime.no_namespace_schema_location_hints().to_vec();
if let Err(e) = runtime.end_validation() {
errors.push(e);
}
let error_msgs: Vec<String> = errors.iter().map(|e| format!("{}", e)).collect();
let actual_outcome = if !errors.is_empty() {
InstanceActualOutcome::Invalid
} else {
match handler.root_validity.unwrap_or(SchemaValidity::NotKnown) {
SchemaValidity::Valid => InstanceActualOutcome::Valid,
SchemaValidity::Invalid => InstanceActualOutcome::Invalid,
SchemaValidity::NotKnown => InstanceActualOutcome::NotKnown,
}
};
Ok((actual_outcome, error_msgs, sl_hints, nnsl_hints))
}
#[derive(Debug, Default)]
struct DtdEntityExpandingLoader;
impl xsd_schema::SchemaLoader for DtdEntityExpandingLoader {
fn load(&self, location: &str) -> xsd_schema::SchemaResult<String> {
let bytes = std::fs::read(location).map_err(|e| {
xsd_schema::SchemaError::resolution(format!("Failed to read '{}': {}", location, e))
})?;
let content = xsd_schema::decode_xml_bytes(bytes)?;
Ok(expand_dtd_entities_if_needed(content))
}
fn can_load(&self, location: &str) -> bool {
!location.starts_with("http://")
&& !location.starts_with("https://")
&& !location.starts_with("embedded://")
}
fn priority(&self) -> i32 {
5 }
}
fn make_schema_builder(version: &str) -> xsd_schema::SchemaSetBuilder {
let mut chain = xsd_schema::LoaderChain::new();
chain.add(Box::new(xsd_schema::EmbeddedLoader::new()));
chain.add(Box::new(DtdEntityExpandingLoader));
if version == "1.1" {
xsd_schema::SchemaSetBuilder::xsd11_with_loader(Box::new(chain))
} else {
xsd_schema::SchemaSetBuilder::with_loader(Box::new(chain))
}
}
fn expand_dtd_entities_if_needed(content: String) -> String {
match expand_dtd_entities(&content) {
Some(expanded) => expanded,
None => content,
}
}
fn expand_dtd_entities(content: &str) -> Option<String> {
let doctype_start = content.find("<!DOCTYPE")?;
if !content[doctype_start..].contains("<!ENTITY") {
return None;
}
let after_dt = &content[doctype_start..];
let bracket_rel = after_dt.find('[')?;
let bracket_start = doctype_start + bracket_rel + 1;
let bracket_end = find_subset_end(content, bracket_start)?;
let internal_subset = &content[bracket_start..bracket_end];
let mut entities = parse_entity_declarations(internal_subset);
if entities.is_empty() {
return None;
}
for _ in 0..30 {
let prev = entities.clone();
for val in entities.values_mut() {
*val = expand_refs(val, &prev);
}
if entities == prev {
break;
}
}
let after_bracket = &content[bracket_end + 1..];
let close_rel = after_bracket.find('>')?;
let doctype_end = bracket_end + 1 + close_rel + 1;
let mut result = String::with_capacity(content.len() * 2);
result.push_str(&content[..doctype_start]);
result.push_str(&expand_refs(&content[doctype_end..], &entities));
Some(result)
}
fn find_subset_end(content: &str, start: usize) -> Option<usize> {
let bytes = content.as_bytes();
let mut i = start;
while i < bytes.len() {
if content[i..].starts_with("<!--") {
i += 4;
while i < bytes.len() {
if content[i..].starts_with("-->") {
i += 3;
break;
}
i += 1;
}
} else if bytes[i] == b'"' {
i += 1;
while i < bytes.len() && bytes[i] != b'"' {
i += 1;
}
if i < bytes.len() {
i += 1;
}
} else if bytes[i] == b'\'' {
i += 1;
while i < bytes.len() && bytes[i] != b'\'' {
i += 1;
}
if i < bytes.len() {
i += 1;
}
} else if bytes[i] == b']' {
return Some(i);
} else {
i += 1;
}
}
None
}
fn parse_entity_declarations(subset: &str) -> HashMap<String, String> {
let mut entities = HashMap::new();
let mut pos = 0;
while pos < subset.len() {
let Some(rel) = subset[pos..].find("<!ENTITY") else {
break;
};
pos += rel + 8;
let ws = subset[pos..].len() - subset[pos..].trim_start().len();
pos += ws;
if subset[pos..].starts_with('%') {
if let Some(end_rel) = subset[pos..].find('>') {
pos += end_rel + 1;
}
continue;
}
let name_end = subset[pos..]
.find(|c: char| c.is_ascii_whitespace())
.unwrap_or(subset.len() - pos);
if name_end == 0 {
pos += 1;
continue;
}
let name = subset[pos..pos + name_end].to_string();
pos += name_end;
let ws = subset[pos..].len() - subset[pos..].trim_start().len();
pos += ws;
if pos >= subset.len() {
break;
}
let quote = subset.as_bytes()[pos];
if quote != b'"' && quote != b'\'' {
if let Some(end_rel) = subset[pos..].find('>') {
pos += end_rel + 1;
}
continue;
}
let quote_char = quote as char;
pos += 1;
let val_end = subset[pos..].find(quote_char).unwrap_or(subset.len() - pos);
let value = subset[pos..pos + val_end].to_string();
pos += val_end + 1;
entities.entry(name).or_insert(value);
if let Some(end_rel) = subset[pos..].find('>') {
pos += end_rel + 1;
}
}
entities
}
fn expand_refs(text: &str, entities: &HashMap<String, String>) -> String {
if !text.contains('&') {
return text.to_string();
}
let mut result = String::with_capacity(text.len() * 2);
let mut remaining = text;
while let Some(amp) = remaining.find('&') {
result.push_str(&remaining[..amp]);
let after = &remaining[amp + 1..];
if let Some(semi) = after.find(';') {
let name = &after[..semi];
match name {
"amp" | "lt" | "gt" | "apos" | "quot" => {
result.push('&');
result.push_str(name);
result.push(';');
}
_ if name.starts_with('#') => {
result.push('&');
result.push_str(name);
result.push(';');
}
_ => {
if let Some(val) = entities.get(name) {
result.push_str(val);
} else {
result.push('&');
result.push_str(name);
result.push(';');
}
}
}
remaining = &after[semi + 1..];
} else {
result.push('&');
remaining = after;
}
}
result.push_str(remaining);
result
}
pub fn print_summary(stats_by_group: &HashMap<String, TestStats>) {
println!("\n=== Test Summary ===\n");
let mut total = TestStats::default();
for (group, stats) in stats_by_group {
println!(
"{}: {} passed, {} failed, {} skipped, {} errors ({:.1}% pass rate)",
group,
stats.passed,
stats.failed,
stats.skipped,
stats.errors,
stats.pass_rate() * 100.0
);
total.passed += stats.passed;
total.failed += stats.failed;
total.skipped += stats.skipped;
total.errors += stats.errors;
total.total_duration += stats.total_duration;
}
println!(
"\nTotal: {} tests, {} passed, {} failed, {} skipped, {} errors",
total.total(),
total.passed,
total.failed,
total.skipped,
total.errors
);
println!("Pass rate: {:.1}%", total.pass_rate() * 100.0);
println!("Duration: {:?}", total.total_duration);
}
fn main() {
let args: Vec<String> = env::args().collect();
let mut test_suite_path: Option<PathBuf> = None;
let mut group_filter: Option<String> = None;
let mut version_filter: Option<String> = None;
let mut name_filters: Vec<String> = Vec::new();
let mut max_tests: usize = 0;
let mut verbose = false;
let mut expect_pass = false;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--test-suite" | "-s" => {
if i + 1 < args.len() {
test_suite_path = Some(PathBuf::from(&args[i + 1]));
i += 1;
}
}
"--group" | "-g" => {
if i + 1 < args.len() {
group_filter = Some(args[i + 1].clone());
i += 1;
}
}
"--version" | "-V" => {
if i + 1 < args.len() {
version_filter = Some(args[i + 1].clone());
i += 1;
}
}
"--name" | "-n" => {
if i + 1 < args.len() {
name_filters.push(args[i + 1].clone());
i += 1;
}
}
"--max" | "-m" => {
if i + 1 < args.len() {
max_tests = args[i + 1].parse().unwrap_or(0);
i += 1;
}
}
"--verbose" | "-v" => {
verbose = true;
}
"--expect-pass" => {
expect_pass = true;
}
"--help" | "-h" => {
println!("XSD Conformance Test Driver");
println!();
println!("Usage: conformance [OPTIONS]");
println!();
println!("Options:");
println!(" -s, --test-suite PATH Path to W3C test suite directory");
println!(" -g, --group NAME Filter by test group name");
println!(" -n, --name PATTERN Filter by test name (substring, repeatable)");
println!(" -V, --version VER Filter by XSD version (1.0 or 1.1)");
println!(" -m, --max NUM Maximum number of tests to run");
println!(" -v, --verbose Enable verbose output");
println!(" --expect-pass Exit non-zero if any test fails or errors");
println!(" -h, --help Show this help message");
return;
}
_ => {}
}
i += 1;
}
let test_suite_path = match test_suite_path {
Some(p) => p,
None => {
println!("XSD Conformance Test Driver");
println!();
println!("No test suite path specified. Use --test-suite /path/to/xsdtests");
println!();
println!("To run conformance tests, you need the W3C XSD test suite.");
println!("Download from: https://www.w3.org/XML/2004/xml-schema-test-suite/");
return;
}
};
if !test_suite_path.exists() {
eprintln!(
"Error: Test suite path does not exist: {:?}",
test_suite_path
);
std::process::exit(1);
}
println!("Loading test suite from {:?}...", test_suite_path);
let parser = TestSuiteParser::new(test_suite_path);
let tests = match parser.parse_manifest() {
Ok(t) => {
println!("Loaded {} tests from manifest", t.len());
t
}
Err(_) => {
println!("No manifest found, scanning for .xsd files...");
match parser.scan_for_tests() {
Ok(t) => {
println!("Found {} test files", t.len());
t
}
Err(e) => {
eprintln!("Error scanning for tests: {}", e);
std::process::exit(1);
}
}
}
};
if tests.is_empty() {
println!("No tests found!");
return;
}
let runner = TestRunner::new(tests)
.with_group_filter(group_filter)
.with_version_filter(version_filter)
.with_name_filters(name_filters)
.with_max_tests(max_tests)
.with_verbose(verbose);
let (_results, stats_by_group) = runner.run();
print_summary(&stats_by_group);
if expect_pass {
let total_failed: usize = stats_by_group.values().map(|s| s.failed + s.errors).sum();
if total_failed > 0 {
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn parse_schema_indeterminate_as_non_asserting() {
assert_eq!(
super::parse_expected_outcome("indeterminate", false).unwrap(),
super::ExpectedOutcome::Indeterminate
);
}
#[test]
fn parse_instance_not_known_preserves_expected_outcome() {
assert_eq!(
super::parse_expected_outcome("notKnown", true).unwrap(),
super::ExpectedOutcome::InstanceNotKnown
);
}
#[test]
fn test_stats_pass_rate() {
let mut stats = super::TestStats::default();
stats.passed = 80;
stats.failed = 20;
assert_eq!(stats.pass_rate(), 0.8);
}
#[test]
fn test_stats_total() {
let mut stats = super::TestStats::default();
stats.passed = 10;
stats.failed = 5;
stats.skipped = 3;
stats.errors = 2;
assert_eq!(stats.total(), 20);
}
#[test]
fn test_empty_stats() {
let stats = super::TestStats::default();
assert_eq!(stats.pass_rate(), 0.0);
assert_eq!(stats.total(), 0);
}
}