use std::env;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::process;
use xdoc::core::{Document, NodeId, NodeKind, QName};
use xdoc::query::{Query, QueryValue};
use xdoc::schema::{ValidationReport, ValidationSeverity, ValueType, XmlContract};
use xdoc::signature::{canonicalize_document, CanonicalizationAlgorithm, CanonicalizationConfig};
use xdoc::writer::{
escape_attribute, escape_text, to_string_compact, to_string_pretty, WriterConfig,
};
fn main() {
let args = env::args().skip(1).collect::<Vec<_>>();
match run(&args) {
Ok(output) => print!("{output}"),
Err(error) => {
eprintln!("{error}");
process::exit(error.exit_code());
}
}
}
fn run(args: &[String]) -> CliResult<String> {
match parse_command(args)? {
Command::Help => Ok(format!("{}\n", usage())),
Command::Format { input } => {
let document = read_document(&input)?;
let xml = to_string_pretty(&document, WriterConfig::pretty())?;
Ok(format!("{xml}\n"))
}
Command::Compact { input } => {
let document = read_document(&input)?;
let xml = to_string_compact(&document)?;
Ok(format!("{xml}\n"))
}
Command::Query { input, query } => {
let document = read_document(&input)?;
let result = Query::parse(&query)?.evaluate(&document)?;
let lines = result
.values()
.iter()
.map(|value| format_query_value(&document, value))
.collect::<CliResult<Vec<_>>>()?;
if lines.is_empty() {
Ok(String::new())
} else {
Ok(format!("{}\n", lines.join("\n")))
}
}
Command::Parse { input } => {
read_document(&input)?;
Ok("ok\n".to_owned())
}
Command::Validate { input, contract } => {
let document = read_document(&input)?;
let contract = read_contract(&contract)?;
let report = contract.validate(&document)?;
let output = format_validation_report(&report);
if report.is_valid() {
Ok(output)
} else {
Err(CliError::runtime(output))
}
}
Command::Canonicalize { input, algorithm } => {
let document = read_document(&input)?;
let config = CanonicalizationConfig::new(algorithm);
let bytes = canonicalize_document(&document, &config)?;
let xml = String::from_utf8(bytes).map_err(|error| {
CliError::runtime(format!("canonicalized XML must be UTF-8: {error}"))
})?;
Ok(format!("{xml}\n"))
}
}
}
fn parse_command(args: &[String]) -> CliResult<Command> {
match args {
[] => Err(CliError::usage()),
[command] if command == "--help" || command == "-h" || command == "help" => {
Ok(Command::Help)
}
[command, input] if command == "format" => Ok(Command::Format {
input: PathBuf::from(input),
}),
[command, input] if command == "compact" => Ok(Command::Compact {
input: PathBuf::from(input),
}),
[command, input] if command == "parse" || command == "validate-wellformed" => {
Ok(Command::Parse {
input: PathBuf::from(input),
})
}
[command, input, query] if command == "query" => Ok(Command::Query {
input: PathBuf::from(input),
query: query.clone(),
}),
[command, input, contract] if command == "validate" => Ok(Command::Validate {
input: PathBuf::from(input),
contract: PathBuf::from(contract),
}),
[command, input] if command == "canonicalize" => Ok(Command::Canonicalize {
input: PathBuf::from(input),
algorithm: CanonicalizationAlgorithm::CanonicalXml11,
}),
[command, flag, algorithm, input] if command == "canonicalize" && flag == "--algorithm" => {
Ok(Command::Canonicalize {
input: PathBuf::from(input),
algorithm: parse_canonicalization_algorithm(algorithm)?,
})
}
[command, input, flag, algorithm] if command == "canonicalize" && flag == "--algorithm" => {
Ok(Command::Canonicalize {
input: PathBuf::from(input),
algorithm: parse_canonicalization_algorithm(algorithm)?,
})
}
_ => Err(CliError::usage()),
}
}
fn read_document(path: &Path) -> CliResult<Document> {
let xml = fs::read_to_string(path)
.map_err(|error| CliError::runtime(format!("cannot read {}: {error}", path.display())))?;
xdoc::parser::parse_str(&xml).map_err(CliError::from)
}
fn read_contract(path: &Path) -> CliResult<XmlContract> {
let source = fs::read_to_string(path)
.map_err(|error| CliError::runtime(format!("cannot read {}: {error}", path.display())))?;
parse_contract(&source)
}
fn format_query_value(document: &Document, value: &QueryValue) -> CliResult<String> {
match value {
QueryValue::Node(id) => serialize_node(document, *id),
QueryValue::Text(value) => Ok(value.clone()),
QueryValue::Attribute { name, value } => {
Ok(format!("@{}=\"{}\"", name, escape_attribute(value)))
}
}
}
fn serialize_node(document: &Document, id: NodeId) -> CliResult<String> {
let mut output = String::new();
write_node(document, id, &mut output)?;
Ok(output)
}
fn write_node(document: &Document, id: NodeId, output: &mut String) -> CliResult<()> {
match document.node(id)?.kind() {
NodeKind::Element(element) => {
output.push('<');
write_qname(element.name(), output);
for declaration in element.namespace_declarations() {
output.push(' ');
match declaration.prefix() {
Some(prefix) => {
output.push_str("xmlns:");
output.push_str(prefix.as_str());
}
None => output.push_str("xmlns"),
}
output.push_str("=\"");
output.push_str(&escape_attribute(declaration.uri().as_str()));
output.push('"');
}
for attribute in element.attributes() {
output.push(' ');
write_qname(attribute.name(), output);
output.push_str("=\"");
output.push_str(&escape_attribute(attribute.value()));
output.push('"');
}
if element.children().is_empty() {
output.push_str("/>");
} else {
output.push('>');
for child in element.children() {
write_node(document, *child, output)?;
}
output.push_str("</");
write_qname(element.name(), output);
output.push('>');
}
}
NodeKind::Text(text) => output.push_str(&escape_text(text)),
NodeKind::Comment(comment) => {
output.push_str("<!--");
output.push_str(comment);
output.push_str("-->");
}
NodeKind::CData(cdata) => {
output.push_str("<![CDATA[");
output.push_str(cdata);
output.push_str("]]>");
}
NodeKind::ProcessingInstruction { target, data } => {
output.push_str("<?");
output.push_str(target);
if let Some(data) = data {
output.push(' ');
output.push_str(data);
}
output.push_str("?>");
}
}
Ok(())
}
fn write_qname(name: &QName, output: &mut String) {
output.push_str(&name.lexical_name());
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Command {
Help,
Format {
input: PathBuf,
},
Compact {
input: PathBuf,
},
Query {
input: PathBuf,
query: String,
},
Parse {
input: PathBuf,
},
Validate {
input: PathBuf,
contract: PathBuf,
},
Canonicalize {
input: PathBuf,
algorithm: CanonicalizationAlgorithm,
},
}
type CliResult<T> = Result<T, CliError>;
#[derive(Debug)]
struct CliError {
message: String,
code: i32,
}
impl CliError {
fn usage() -> Self {
Self {
message: usage().to_owned(),
code: 2,
}
}
fn runtime(message: String) -> Self {
Self { message, code: 1 }
}
fn exit_code(&self) -> i32 {
self.code
}
}
impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl From<xdoc::core::XmlError> for CliError {
fn from(error: xdoc::core::XmlError) -> Self {
Self::runtime(error.to_string())
}
}
fn usage() -> &'static str {
"usage:
xdoc --help
xdoc format <input.xml>
xdoc compact <input.xml>
xdoc parse <input.xml>
xdoc query <input.xml> <query>
xdoc validate <input.xml> <contract.toml>
xdoc canonicalize [--algorithm c14n11|c14n10] <input.xml>
xdoc validate-wellformed <input.xml>"
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct ContractSpec {
name: Option<String>,
namespaces: Vec<NamespaceSpec>,
required: Vec<String>,
cardinality: Vec<CardinalitySpec>,
text_types: Vec<TextTypeSpec>,
enums: Vec<EnumSpec>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct NamespaceSpec {
alias: Option<String>,
uri: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct CardinalitySpec {
path: Option<String>,
min: Option<usize>,
max: Option<usize>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct TextTypeSpec {
path: Option<String>,
value_type: Option<ValueType>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct EnumSpec {
path: Option<String>,
values: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ContractSection {
Root,
Namespace(usize),
Cardinality(usize),
TextType(usize),
Enum(usize),
}
fn parse_contract(source: &str) -> CliResult<XmlContract> {
let mut spec = ContractSpec::default();
let mut section = ContractSection::Root;
for (index, raw_line) in source.lines().enumerate() {
let line_number = index + 1;
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
section = match line {
"[[namespace]]" => {
spec.namespaces.push(NamespaceSpec::default());
ContractSection::Namespace(spec.namespaces.len() - 1)
}
"[[cardinality]]" => {
spec.cardinality.push(CardinalitySpec::default());
ContractSection::Cardinality(spec.cardinality.len() - 1)
}
"[[text_type]]" => {
spec.text_types.push(TextTypeSpec::default());
ContractSection::TextType(spec.text_types.len() - 1)
}
"[[enum]]" => {
spec.enums.push(EnumSpec::default());
ContractSection::Enum(spec.enums.len() - 1)
}
_ => {
parse_contract_assignment(&mut spec, section, line, line_number)?;
section
}
};
}
build_contract(spec)
}
fn parse_contract_assignment(
spec: &mut ContractSpec,
section: ContractSection,
line: &str,
line_number: usize,
) -> CliResult<()> {
let (key, value) = line.split_once('=').ok_or_else(|| {
CliError::runtime(format!(
"contract line {line_number}: expected `key = value`, found `{line}`"
))
})?;
let key = key.trim();
let value = value.trim();
match section {
ContractSection::Root => match key {
"name" => spec.name = Some(parse_string(value, line_number)?),
"required" => spec.required = parse_string_array(value, line_number)?,
_ => return Err(unknown_contract_key(line_number, key)),
},
ContractSection::Namespace(index) => {
let namespace = &mut spec.namespaces[index];
match key {
"alias" => namespace.alias = Some(parse_string(value, line_number)?),
"uri" => namespace.uri = Some(parse_string(value, line_number)?),
_ => return Err(unknown_contract_key(line_number, key)),
}
}
ContractSection::Cardinality(index) => {
let cardinality = &mut spec.cardinality[index];
match key {
"path" => cardinality.path = Some(parse_string(value, line_number)?),
"min" => cardinality.min = Some(parse_usize(value, line_number)?),
"max" => cardinality.max = Some(parse_usize(value, line_number)?),
_ => return Err(unknown_contract_key(line_number, key)),
}
}
ContractSection::TextType(index) => {
let text_type = &mut spec.text_types[index];
match key {
"path" => text_type.path = Some(parse_string(value, line_number)?),
"type" => {
text_type.value_type = Some(parse_value_type(
&parse_string(value, line_number)?,
line_number,
)?)
}
_ => return Err(unknown_contract_key(line_number, key)),
}
}
ContractSection::Enum(index) => {
let enum_spec = &mut spec.enums[index];
match key {
"path" => enum_spec.path = Some(parse_string(value, line_number)?),
"values" => enum_spec.values = parse_string_array(value, line_number)?,
_ => return Err(unknown_contract_key(line_number, key)),
}
}
}
Ok(())
}
fn build_contract(spec: ContractSpec) -> CliResult<XmlContract> {
let mut contract = XmlContract::new(spec.name.unwrap_or_else(|| "contract".to_owned()));
for namespace in spec.namespaces {
let alias = namespace
.alias
.ok_or_else(|| CliError::runtime("[[namespace]] requires `alias`".to_owned()))?;
let uri = namespace
.uri
.ok_or_else(|| CliError::runtime("[[namespace]] requires `uri`".to_owned()))?;
contract = contract.with_namespace(alias, uri)?;
}
for required in spec.required {
contract = contract.required(required)?;
}
for cardinality in spec.cardinality {
let path = cardinality
.path
.ok_or_else(|| CliError::runtime("[[cardinality]] requires `path`".to_owned()))?;
contract = contract.cardinality(path, cardinality.min.unwrap_or(0), cardinality.max)?;
}
for text_type in spec.text_types {
let path = text_type
.path
.ok_or_else(|| CliError::runtime("[[text_type]] requires `path`".to_owned()))?;
let value_type = text_type
.value_type
.ok_or_else(|| CliError::runtime("[[text_type]] requires `type`".to_owned()))?;
contract = contract.text_type(path, value_type)?;
}
for enum_spec in spec.enums {
let path = enum_spec
.path
.ok_or_else(|| CliError::runtime("[[enum]] requires `path`".to_owned()))?;
contract = contract.enum_value(path, enum_spec.values)?;
}
Ok(contract)
}
fn parse_string(value: &str, line_number: usize) -> CliResult<String> {
let value = value.trim();
if let Some(value) = value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
{
Ok(value.replace("\\\"", "\""))
} else {
Err(CliError::runtime(format!(
"contract line {line_number}: expected quoted string, found `{value}`"
)))
}
}
fn parse_string_array(value: &str, line_number: usize) -> CliResult<Vec<String>> {
let value = value.trim();
let inner = value
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.ok_or_else(|| {
CliError::runtime(format!(
"contract line {line_number}: expected string array, found `{value}`"
))
})?;
let mut values = Vec::new();
for item in inner.split(',') {
let item = item.trim();
if item.is_empty() {
continue;
}
values.push(parse_string(item, line_number)?);
}
Ok(values)
}
fn parse_usize(value: &str, line_number: usize) -> CliResult<usize> {
value.trim().parse::<usize>().map_err(|error| {
CliError::runtime(format!(
"contract line {line_number}: expected unsigned integer, found `{value}`: {error}"
))
})
}
fn parse_value_type(value: &str, line_number: usize) -> CliResult<ValueType> {
match value {
"string" => Ok(ValueType::String),
"integer" => Ok(ValueType::Integer),
"decimal" => Ok(ValueType::Decimal),
"boolean" => Ok(ValueType::Boolean),
_ => Err(CliError::runtime(format!(
"contract line {line_number}: unknown value type `{value}`"
))),
}
}
fn parse_canonicalization_algorithm(value: &str) -> CliResult<CanonicalizationAlgorithm> {
match value {
"c14n11" | "xml-c14n11" => Ok(CanonicalizationAlgorithm::CanonicalXml11),
"c14n10" | "xml-c14n10" => Ok(CanonicalizationAlgorithm::CanonicalXml10),
uri => CanonicalizationAlgorithm::from_uri(uri).map_err(CliError::from),
}
}
fn unknown_contract_key(line_number: usize, key: &str) -> CliError {
CliError::runtime(format!("contract line {line_number}: unknown key `{key}`"))
}
fn format_validation_report(report: &ValidationReport) -> String {
let mut output = String::new();
if report.is_valid() {
output.push_str("valid: ");
} else {
output.push_str("invalid: ");
}
output.push_str(report.contract_name());
output.push('\n');
for issue in report.issues() {
let severity = match issue.severity() {
ValidationSeverity::Error => "error",
ValidationSeverity::Warning => "warning",
};
output.push_str(severity);
output.push(' ');
output.push_str(issue.path());
output.push_str(": ");
output.push_str(issue.message());
output.push('\n');
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn compact_writes_single_line_xml() {
let input = write_temp_xml("<Root>\n <Child>value</Child>\n</Root>");
let output = run(&["compact".to_owned(), input.display().to_string()]).expect("compact");
assert_eq!(output, "<Root>\n <Child>value</Child>\n</Root>\n");
}
#[test]
fn format_writes_pretty_xml() {
let input = write_temp_xml("<Root><Child>value</Child></Root>");
let output = run(&["format".to_owned(), input.display().to_string()]).expect("format");
assert_eq!(output, "<Root>\n <Child>value</Child>\n</Root>\n");
}
#[test]
fn query_writes_matching_nodes() {
let input =
write_temp_xml(r#"<Root><Item code="A">one</Item><Item code="B">two</Item></Root>"#);
let output = run(&[
"query".to_owned(),
input.display().to_string(),
"/Root/Item[@code='B']".to_owned(),
])
.expect("query");
assert_eq!(output, "<Item code=\"B\">two</Item>\n");
}
#[test]
fn validate_wellformed_writes_ok_for_valid_xml() {
let input = write_temp_xml("<Root/>");
let output = run(&[
"validate-wellformed".to_owned(),
input.display().to_string(),
])
.expect("validate");
assert_eq!(output, "ok\n");
}
#[test]
fn parse_writes_ok_for_valid_xml() {
let input = write_temp_xml("<Root/>");
let output = run(&["parse".to_owned(), input.display().to_string()]).expect("parse");
assert_eq!(output, "ok\n");
}
#[test]
fn validate_reports_valid_contract() {
let input =
write_temp_xml(r#"<Root><Item code="A">one</Item><Item code="B">two</Item></Root>"#);
let contract = write_temp_contract(
r#"name = "SimpleList"
required = ["/Root/Item"]
[[cardinality]]
path = "/Root/Item"
min = 2
max = 2
[[enum]]
path = "/Root/Item[@code='B']/text()"
values = ["two"]
"#,
);
let output = run(&[
"validate".to_owned(),
input.display().to_string(),
contract.display().to_string(),
])
.expect("validate");
assert_eq!(output, "valid: SimpleList\n");
}
#[test]
fn validate_returns_nonzero_error_for_invalid_contract_report() {
let input = write_temp_xml(r#"<Root><Item code="A">one</Item></Root>"#);
let contract = write_temp_contract(
r#"name = "SimpleList"
required = ["/Root/Missing"]
[[cardinality]]
path = "/Root/Item"
min = 2
"#,
);
let error = run(&[
"validate".to_owned(),
input.display().to_string(),
contract.display().to_string(),
])
.expect_err("validation report must be invalid");
assert_eq!(error.exit_code(), 1);
assert!(error.to_string().contains("invalid: SimpleList"));
assert!(error.to_string().contains("/Root/Missing"));
assert!(error.to_string().contains("at least 2"));
}
#[test]
fn canonicalize_outputs_canonical_xml() {
let input = write_temp_xml("<Root><Empty/></Root>");
let output =
run(&["canonicalize".to_owned(), input.display().to_string()]).expect("canonicalize");
assert_eq!(output, "<Root><Empty></Empty></Root>\n");
}
#[test]
fn canonicalize_accepts_c14n10_algorithm_flag() {
let input = write_temp_xml("<Root><Empty/></Root>");
let output = run(&[
"canonicalize".to_owned(),
"--algorithm".to_owned(),
"c14n10".to_owned(),
input.display().to_string(),
])
.expect("canonicalize");
assert_eq!(output, "<Root><Empty></Empty></Root>\n");
}
#[test]
fn help_lists_all_commands() {
let output = run(&["--help".to_owned()]).expect("help");
for command in [
"xdoc format",
"xdoc compact",
"xdoc parse",
"xdoc query",
"xdoc validate",
"xdoc canonicalize",
"xdoc validate-wellformed",
] {
assert!(output.contains(command), "{command}");
}
}
#[test]
fn invalid_usage_returns_exit_code_two() {
let error = run(&[]).expect_err("usage error");
assert_eq!(error.exit_code(), 2);
assert!(error.to_string().contains("xdoc format <input.xml>"));
}
fn write_temp_xml(xml: &str) -> PathBuf {
write_temp_file("xml", xml)
}
fn write_temp_contract(contract: &str) -> PathBuf {
write_temp_file("toml", contract)
}
fn write_temp_file(extension: &str, contents: &str) -> PathBuf {
let mut path = env::temp_dir();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
path.push(format!("xdoc-cli-test-{nanos}.{extension}"));
fs::write(&path, contents).expect("write temp file");
path
}
}