1#![forbid(unsafe_code)]
12
13use std::io::{Read as _, Write};
14use std::process::ExitCode;
15
16const USAGE: &str = "\
17oxml — query, validate and inspect XML
18
19USAGE:
20 oxml <COMMAND> [OPTIONS] [FILE]
21
22COMMANDS:
23 query <XPATH> Evaluate an XPath expression
24 validate <XSD> Validate against an XML Schema
25 stats Summarise the document
26 check Report whether the document is well-formed
27
28OPTIONS:
29 -t, --text Print matched nodes' text rather than a summary
30 -c, --count Print only the number of matches
31 -n, --ns P=URI Bind a namespace prefix for `query`, repeatable
32 -h, --help Show this message
33 -V, --version Show the version
34
35FILE defaults to standard input, so oxml composes with pipes:
36
37 curl -s https://example.com/feed.xml | oxml query '//item/title' -t
38
39EXIT STATUS:
40 0 success, and for `validate` the document conforms
41 1 the document is invalid, or no nodes matched
42 2 a usage or I/O error
43";
44
45pub fn run(
59 args: &[String],
60 out: &mut dyn Write,
61 err: &mut dyn Write,
62) -> Result<ExitCode, String> {
63 if args.is_empty() || args.iter().any(|a| a == "-h" || a == "--help") {
64 let _ = write!(out, "{USAGE}");
65 return Ok(ExitCode::SUCCESS);
66 }
67 if args.iter().any(|a| a == "-V" || a == "--version") {
68 let _ = writeln!(out, "oxml {}", env!("CARGO_PKG_VERSION"));
69 return Ok(ExitCode::SUCCESS);
70 }
71
72 let text_only = args.iter().any(|a| a == "-t" || a == "--text");
73 let count_only = args.iter().any(|a| a == "-c" || a == "--count");
74 let namespaces = parse_namespace_bindings(args)?;
75 let positional = positional_args(args);
76
77 let command = positional
78 .first()
79 .ok_or_else(|| "no command given; try `oxml --help`".to_owned())?;
80
81 match command.as_str() {
82 "query" => {
83 let expr = positional
84 .get(1)
85 .ok_or_else(|| "query needs an XPath expression".to_owned())?;
86 let source = read_input(positional.get(2).map(|s| s.as_str()))?;
87 cmd_query(&source, expr, text_only, count_only, &namespaces, out)
88 }
89 "validate" => {
90 let xsd_path = positional
91 .get(1)
92 .ok_or_else(|| "validate needs a path to an .xsd".to_owned())?;
93 let source = read_input(positional.get(2).map(|s| s.as_str()))?;
94 cmd_validate(&source, xsd_path, out, err)
95 }
96 "stats" => {
97 let source = read_input(positional.get(1).map(|s| s.as_str()))?;
98 cmd_stats(&source, out)
99 }
100 "check" => {
101 let source = read_input(positional.get(1).map(|s| s.as_str()))?;
102 Ok(cmd_check(&source, out, err))
103 }
104 other => Err(format!("unknown command `{other}`; try `oxml --help`")),
105 }
106}
107
108fn read_input(path: Option<&str>) -> Result<String, String> {
110 if let Some(p) = path {
111 let file = std::fs::File::open(p)
112 .map_err(|e| format!("cannot read {p}: {e}"))?;
113 read_bounded(file, p)
114 } else {
115 read_bounded(std::io::stdin().lock(), "stdin")
116 }
117}
118
119fn read_bounded(
120 input: impl std::io::Read,
121 label: &str,
122) -> Result<String, String> {
123 const MAX_INPUT_BYTES: usize = 8 * 1024 * 1024;
124 let mut buf = String::new();
125 let _ = input
126 .take((MAX_INPUT_BYTES + 1) as u64)
127 .read_to_string(&mut buf)
128 .map_err(|e| format!("cannot read {label}: {e}"))?;
129 if buf.len() > MAX_INPUT_BYTES {
130 return Err(format!("{label} exceeds the 8 MiB input limit"));
131 }
132 Ok(buf)
133}
134
135fn parse_or_report(source: &str) -> Result<oxml::Document, String> {
136 oxml::parse_with(source, oxml::Limits::strict()).map_err(|e| {
137 let (line, col) = e.line_column(source);
138 format!("{line}:{col}: {e}")
139 })
140}
141
142fn positional_args(args: &[String]) -> Vec<&String> {
158 let mut out: Vec<&String> = Vec::new();
159 let mut skip_next = false;
160 for arg in args {
161 if skip_next {
162 skip_next = false;
163 continue;
164 }
165 if arg == "-n" || arg == "--ns" {
166 skip_next = true;
167 continue;
168 }
169 if !arg.starts_with('-') {
170 out.push(arg);
171 }
172 }
173 out
174}
175
176fn explain_xpath_error(error: &oxml::XPathError) -> String {
183 if error.message.contains("unbound namespace prefix") {
184 let prefix = error
185 .message
186 .split('`')
187 .nth(1)
188 .unwrap_or("PREFIX")
189 .to_owned();
190 format!(
191 "bad XPath: unbound namespace prefix `{prefix}`; \
192 bind it with --ns {prefix}=URI"
193 )
194 } else {
195 format!("bad XPath: {error}")
196 }
197}
198
199fn parse_namespace_bindings(
200 args: &[String],
201) -> Result<Vec<(String, String)>, String> {
202 let mut out: Vec<(String, String)> = Vec::new();
203 let mut expecting = false;
204 for arg in args {
205 if expecting {
206 expecting = false;
207 let Some((prefix, uri)) = arg.split_once('=') else {
208 return Err(format!(
209 "`{arg}` is not a namespace binding; write PREFIX=URI"
210 ));
211 };
212 if prefix.is_empty() {
213 return Err("a namespace binding needs a prefix".to_owned());
214 }
215 if prefix == "xml" {
216 return Err(
217 "`xml` is bound by the specification and may not be \
218 rebound"
219 .to_owned(),
220 );
221 }
222 out.retain(|(p, _)| p != prefix);
225 out.push((prefix.to_owned(), uri.to_owned()));
226 } else if arg == "-n" || arg == "--ns" {
227 expecting = true;
228 }
229 }
230 if expecting {
231 return Err("`--ns` needs a PREFIX=URI argument".to_owned());
232 }
233 Ok(out)
234}
235
236fn evaluate_query(
237 source: &str,
238 expr: &str,
239 namespaces: &[(String, String)],
240) -> Result<(oxml::Document, oxml::xpath::Value), String> {
241 let doc = parse_or_report(source)?;
242 let bindings: Vec<(&str, &str)> = namespaces
243 .iter()
244 .map(|(prefix, uri)| (prefix.as_str(), uri.as_str()))
245 .collect();
246 let xpath = oxml::XPath::compile_with_namespaces_and_limits(
247 expr,
248 &bindings,
249 &oxml::Limits::strict(),
250 )
251 .map_err(|e| explain_xpath_error(&e))?;
252 let context = oxml::xpath::EvalContext::new()
253 .with_limits(oxml::xpath::EvalLimits::default());
254 let value = xpath
255 .evaluate_with(&doc, &context)
256 .map_err(|e| e.to_string())?;
257
258 Ok((doc, value))
259}
260
261fn cmd_query(
262 source: &str,
263 expr: &str,
264 text_only: bool,
265 count_only: bool,
266 namespaces: &[(String, String)],
267 out: &mut dyn Write,
268) -> Result<ExitCode, String> {
269 let (doc, value) = evaluate_query(source, expr, namespaces)?;
270
271 let Some(nodes) = value.nodes() else {
272 let _ = writeln!(out, "{}", value.to_str(&doc));
276 return Ok(ExitCode::SUCCESS);
277 };
278
279 if count_only {
280 let _ = writeln!(out, "{}", nodes.len());
281 return Ok(if nodes.is_empty() {
282 ExitCode::from(1)
283 } else {
284 ExitCode::SUCCESS
285 });
286 }
287
288 for &node in nodes {
289 let line = if text_only {
290 doc.text(node)
291 } else {
292 describe(&doc, node)
293 };
294 writeln!(out, "{line}").map_err(|e| e.to_string())?;
295 }
296
297 Ok(if nodes.is_empty() {
298 ExitCode::from(1)
299 } else {
300 ExitCode::SUCCESS
301 })
302}
303
304fn describe(doc: &oxml::Document, node: oxml::NodeId) -> String {
306 match doc.kind(node) {
307 Some(oxml::NodeKind::Element { .. }) => {
312 let Some(name) = doc.element_name(node) else {
313 return String::new();
314 };
315 let attrs: Vec<String> = doc
316 .attributes(node)
317 .iter()
318 .map(|a| {
319 let name =
320 doc.name(a.name).map_or("?", |n| n.local.as_str());
321 format!(" {name}=\"{}\"", a.value)
322 })
323 .collect();
324 let text = doc.text(node);
325 let preview = text.trim();
326 if preview.is_empty() {
327 format!("<{}{}/>", name.local, attrs.concat())
328 } else {
329 format!(
330 "<{}{}>{}</{}>",
331 name.local,
332 attrs.concat(),
333 truncate(preview, 60),
334 name.local
335 )
336 }
337 }
338 Some(oxml::NodeKind::Attr(a)) => {
339 let name = doc.name(a.name).map_or("?", |n| n.local.as_str());
340 format!("{name}=\"{}\"", a.value)
341 }
342 Some(oxml::NodeKind::Text(t)) => truncate(t.trim(), 80),
343 Some(oxml::NodeKind::Comment(c)) => {
344 format!("<!--{}-->", truncate(c.trim(), 60))
345 }
346 _ => String::new(),
347 }
348}
349
350fn truncate(s: &str, max: usize) -> String {
351 if s.chars().count() <= max {
352 return s.to_owned();
353 }
354 let head: String = s.chars().take(max).collect();
355 format!("{head}…")
356}
357
358fn cmd_validate(
359 source: &str,
360 xsd_path: &str,
361 out: &mut dyn Write,
362 err: &mut dyn Write,
363) -> Result<ExitCode, String> {
364 let xsd = read_input(Some(xsd_path))?;
365 let schema = xmlschema::parse_schema(&xsd)
366 .map_err(|e| format!("bad schema: {e}"))?;
367 let doc = parse_or_report(source)?;
368 let report = xmlschema::validate(&doc, &schema);
369
370 if report.is_valid() {
371 let _ = writeln!(out, "valid");
372 return Ok(ExitCode::SUCCESS);
373 }
374 let _ = writeln!(err, "{} violation(s):", report.violations.len());
377 for v in &report.violations {
378 let _ = writeln!(err, " {} — {}", v.path, v.message);
379 }
380 Ok(ExitCode::from(1))
381}
382
383fn cmd_stats(source: &str, out: &mut dyn Write) -> Result<ExitCode, String> {
384 let doc = parse_or_report(source)?;
385 let mut elements = 0usize;
386 let mut attributes = 0usize;
387 let mut text = 0usize;
388 let mut comments = 0usize;
389 let mut namespaces = 0usize;
390 let mut others = 0usize;
391 let mut depth_max = 0usize;
392
393 for id in doc.descendants() {
394 match doc.kind(id) {
395 Some(oxml::NodeKind::Element { .. }) => {
396 elements += 1;
397 let mut d = 0usize;
398 let mut cur = Some(id);
399 while let Some(n) = cur {
400 cur = doc.parent(n);
401 d += 1;
402 }
403 depth_max = depth_max.max(d);
404 }
405 Some(oxml::NodeKind::Attr(_)) => attributes += 1,
406 Some(oxml::NodeKind::Text(_)) => text += 1,
407 Some(oxml::NodeKind::Comment(_)) => comments += 1,
408 Some(oxml::NodeKind::Namespace { .. }) => namespaces += 1,
409 _ => others += 1,
413 }
414 }
415
416 let _ = writeln!(out, "nodes {}", doc.len());
423 let _ = writeln!(out, "elements {elements}");
424 let _ = writeln!(out, "attributes {attributes}");
425 let _ = writeln!(out, "text {text}");
426 let _ = writeln!(out, "comments {comments}");
427 let _ = writeln!(out, "namespaces {namespaces}");
428 let _ = writeln!(out, "other {others}");
429 let _ = writeln!(out, "max depth {depth_max}");
430 debug_assert_eq!(
431 elements + attributes + text + comments + namespaces + others,
432 doc.len(),
433 "the breakdown must account for every node"
434 );
435 Ok(ExitCode::SUCCESS)
436}
437
438fn cmd_check(
441 source: &str,
442 out: &mut dyn Write,
443 err: &mut dyn Write,
444) -> ExitCode {
445 match oxml::parse_with(source, oxml::Limits::strict()) {
446 Ok(doc) => {
447 let _ = writeln!(out, "well-formed ({} nodes)", doc.len());
448 ExitCode::SUCCESS
449 }
450 Err(e) => {
451 let (line, col) = e.line_column(source);
452 let _ = writeln!(err, "not well-formed at {line}:{col}: {e}");
453 ExitCode::from(1)
454 }
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::parse_namespace_bindings;
461
462 fn args(list: &[&str]) -> Vec<String> {
463 list.iter().map(|s| (*s).to_owned()).collect()
464 }
465
466 struct Temp(std::path::PathBuf);
468
469 impl Temp {
470 fn new(name: &str, contents: &str) -> Self {
471 let path = std::env::temp_dir().join(name);
472 std::fs::write(&path, contents).expect("write scratch file");
473 Self(path)
474 }
475 fn path(&self) -> &str {
476 self.0.to_str().expect("utf-8 path")
477 }
478 }
479
480 impl Drop for Temp {
481 fn drop(&mut self) {
482 let _ = std::fs::remove_file(&self.0);
483 }
484 }
485
486 fn query(
493 doc: &str,
494 expr: &str,
495 text_only: bool,
496 count_only: bool,
497 ns: &[(String, String)],
498 ) -> Result<(String, String), String> {
499 let mut out = Vec::new();
500 let code =
501 super::cmd_query(doc, expr, text_only, count_only, ns, &mut out)?;
502 Ok((String::from_utf8(out).expect("utf-8"), format!("{code:?}")))
503 }
504
505 #[test]
506 fn cmd_query_reports_matches_and_their_absence() {
507 let doc = r"<r><t>one</t><t>two</t></r>";
508 let success = format!("{:?}", std::process::ExitCode::SUCCESS);
509
510 let (text, code) = query(doc, "//t", true, false, &[]).expect("valid");
511 assert_eq!(text, "one\ntwo\n");
512 assert_eq!(code, success);
513
514 let (text, code) = query(doc, "//t", false, true, &[]).expect("valid");
515 assert_eq!(text, "2\n");
516 assert_eq!(code, success);
517
518 let (text, code) =
520 query(doc, "count(//t)", false, false, &[]).expect("valid");
521 assert_eq!(text, "2\n");
522 assert_eq!(code, success);
523
524 let (text, code) =
529 query(doc, "//missing", true, false, &[]).expect("valid");
530 assert_eq!(text, "");
531 assert_eq!(code, format!("{:?}", std::process::ExitCode::from(1)));
532 }
533
534 #[test]
535 fn cmd_query_binds_namespaces_and_refuses_unbound_ones() {
536 let doc = r#"<r xmlns:m="urn:u"><m:t>ns</m:t></r>"#;
537 let bound = [(String::from("m"), String::from("urn:u"))];
538 let (text, _) =
539 query(doc, "//m:t", true, false, &bound).expect("bound");
540 assert_eq!(text, "ns\n");
541
542 let err = query(doc, "//m:t", true, false, &[]).expect_err("unbound");
543 assert!(err.contains("--ns"), "{err}");
544 }
545
546 #[test]
547 fn cmd_validate_reads_a_schema_from_disk() {
548 let schema = Temp::new(
549 "oxml-cli-test-schema.xsd",
550 r#"<?xml version="1.0"?>
551<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
552 <xs:element name="r"/>
553</xs:schema>"#,
554 );
555 let (mut out, mut err) = (Vec::new(), Vec::new());
556 assert!(
557 super::cmd_validate("<r/>", schema.path(), &mut out, &mut err)
558 .is_ok()
559 );
560 assert!(
563 super::cmd_validate("<r/>", "/nonexistent.xsd", &mut out, &mut err)
564 .is_err()
565 );
566 }
567
568 fn drive(argv: &[String]) -> Result<(String, String, String), String> {
570 let (mut out, mut err) = (Vec::new(), Vec::new());
571 let code = super::run(argv, &mut out, &mut err)?;
572 Ok((
573 String::from_utf8(out).expect("utf-8"),
574 String::from_utf8(err).expect("utf-8"),
575 format!("{code:?}"),
576 ))
577 }
578
579 #[test]
580 fn run_dispatches_each_command() {
581 let doc = Temp::new("oxml-cli-test-doc.xml", "<r><t>x</t></r>");
582 for command in [
583 vec!["check", doc.path()],
584 vec!["stats", doc.path()],
585 vec!["query", "//t", doc.path()],
586 vec!["query", "-t", "//t", doc.path()],
587 ] {
588 let owned = args(&command);
589 let (out, _, _) = drive(&owned).expect("{command:?}");
590 assert!(!out.is_empty(), "{command:?} printed nothing");
591 }
592 }
593
594 #[test]
595 fn run_answers_help_and_version_without_reading_anything() {
596 for flags in [vec!["--help"], vec!["-h"], vec!["--version"], vec!["-V"]]
599 {
600 let owned = args(&flags);
601 let (out, _, _) = drive(&owned).expect("{flags:?}");
602 assert!(!out.is_empty(), "{flags:?} printed nothing");
603 }
604 }
605
606 #[test]
607 fn run_rejects_a_command_it_does_not_have() {
608 let owned = args(&["frobnicate"]);
609 let err = drive(&owned).expect_err("unknown");
610 assert!(err.contains("unknown command"), "{err}");
611
612 let owned = args(&["query"]);
614 assert!(drive(&owned).is_err());
615 let owned = args(&["validate"]);
616 assert!(drive(&owned).is_err());
617 let owned: Vec<String> = Vec::new();
618 let (usage, _, _) = drive(&owned).expect("no arguments prints usage");
619 assert!(usage.contains("USAGE") || usage.contains("oxml"), "{usage}");
620 }
621
622 #[test]
623 fn truncate_keeps_short_strings_whole() {
624 assert_eq!(super::truncate("short", 10), "short");
625 assert_eq!(super::truncate("exactly10!", 10), "exactly10!");
626 }
627
628 #[test]
629 fn truncate_counts_characters_not_bytes() {
630 assert_eq!(super::truncate("ééééé", 3), "ééé…");
633 assert_eq!(super::truncate("ééééé", 5), "ééééé");
634 }
635
636 #[test]
637 fn describe_renders_each_node_kind() {
638 let doc =
639 super::parse_or_report(r#"<r a="1"><!--note--><?pi go?>text</r>"#)
640 .expect("well-formed");
641 let rendered: Vec<String> = doc
642 .descendants()
643 .map(|id| super::describe(&doc, id))
644 .collect();
645 let all = rendered.join("\n");
646 assert!(
647 all.contains("<r a=\"1\">"),
648 "element with attributes: {all}"
649 );
650 assert!(all.contains("note"), "comment: {all}");
651 assert!(
655 rendered.iter().any(String::is_empty),
656 "a processing instruction renders empty: {rendered:?}"
657 );
658 assert!(all.contains("text"), "text: {all}");
659 }
660
661 #[test]
662 fn parse_or_report_explains_where_the_document_broke() {
663 let err = super::parse_or_report("<a></b>").expect_err("mismatched");
664 assert!(err.contains("1:4"), "{err}");
667 }
668
669 #[test]
670 fn cmd_check_separates_a_bad_document_from_a_good_one() {
671 let (mut out, mut err) = (Vec::new(), Vec::new());
672 assert_eq!(
673 format!("{:?}", super::cmd_check("<a/>", &mut out, &mut err)),
674 format!("{:?}", std::process::ExitCode::SUCCESS)
675 );
676 assert!(
677 String::from_utf8(out)
678 .expect("utf-8")
679 .contains("well-formed"),
680 "a good document says so"
681 );
682
683 let (mut out, mut err) = (Vec::new(), Vec::new());
686 assert_eq!(
687 format!("{:?}", super::cmd_check("<a>", &mut out, &mut err)),
688 format!("{:?}", std::process::ExitCode::from(1))
689 );
690 assert!(out.is_empty(), "a failure printed to stdout");
693 assert!(
694 String::from_utf8(err)
695 .expect("utf-8")
696 .contains("not well-formed"),
697 "a bad document says why"
698 );
699 }
700
701 #[test]
702 fn cmd_stats_counts_a_document_without_touching_the_filesystem() {
703 let mut out = Vec::new();
704 assert!(
705 super::cmd_stats("<r><a/><b>x</b><!--c--></r>", &mut out).is_ok()
706 );
707 let text = String::from_utf8(out).expect("utf-8");
708 assert!(text.contains("elements 3"), "{text}");
709 assert!(text.contains("comments 1"), "{text}");
710 let mut out = Vec::new();
712 assert!(super::cmd_stats("<r>", &mut out).is_err());
713 }
714
715 #[test]
716 fn cmd_stats_accounts_for_every_node() {
717 let doc = r#"<r xmlns:m="urn:u"><m:a k="v">x</m:a><!--c--></r>"#;
722 let mut out = Vec::new();
723 let _ = super::cmd_stats(doc, &mut out).expect("well-formed");
724 let text = String::from_utf8(out).expect("utf-8");
725
726 let value = |label: &str| -> usize {
727 text.lines()
728 .find(|l| l.starts_with(label))
729 .and_then(|l| l.split_whitespace().last())
730 .and_then(|n| n.parse().ok())
731 .unwrap_or_else(|| panic!("no `{label}` line in {text}"))
732 };
733 let total = value("nodes");
734 let parts = [
735 "elements",
736 "attributes",
737 "text",
738 "comments",
739 "namespaces",
740 "other",
741 ]
742 .iter()
743 .map(|l| value(l))
744 .sum::<usize>();
745 assert_eq!(parts, total, "the breakdown does not add up:\n{text}");
746 assert_eq!(value("namespaces"), 2, "{text}");
749 }
750
751 #[test]
752 fn an_option_value_is_not_a_positional_argument() {
753 let owned = args(&["query", "-n", "m=urn:example", "//m:x", "f.xml"]);
757 let got = super::positional_args(&owned);
758 let got: Vec<&str> = got.iter().map(|s| s.as_str()).collect();
759 assert_eq!(got, ["query", "//m:x", "f.xml"]);
760 }
761
762 #[test]
763 fn ordinary_flags_are_still_dropped() {
764 let owned = args(&["query", "-t", "//x", "-c", "f.xml"]);
765 let got = super::positional_args(&owned);
766 let got: Vec<&str> = got.iter().map(|s| s.as_str()).collect();
767 assert_eq!(got, ["query", "//x", "f.xml"]);
768 }
769
770 #[test]
771 fn a_trailing_option_consumes_nothing_that_is_not_there() {
772 let owned = args(&["query", "//x", "-n"]);
773 let got = super::positional_args(&owned);
774 let got: Vec<&str> = got.iter().map(|s| s.as_str()).collect();
775 assert_eq!(got, ["query", "//x"]);
776 }
777
778 #[test]
779 fn the_long_option_form_also_takes_a_value() {
780 let owned = args(&["query", "--ns", "m=urn:u", "//m:x"]);
781 let got = super::positional_args(&owned);
782 let got: Vec<&str> = got.iter().map(|s| s.as_str()).collect();
783 assert_eq!(got, ["query", "//m:x"]);
784 }
785
786 #[test]
787 fn an_unbound_prefix_is_explained_in_terms_of_the_flag() {
788 let err = oxml::XPath::compile("//m:item").expect_err("unbound");
792 let said = super::explain_xpath_error(&err);
793 assert!(said.contains("--ns m=URI"), "{said}");
794 assert!(!said.contains("compile_with_namespaces"), "{said}");
795 }
796
797 #[test]
798 fn other_expression_errors_are_passed_through() {
799 let err = oxml::XPath::compile("//[").expect_err("malformed");
802 let said = super::explain_xpath_error(&err);
803 assert!(said.starts_with("bad XPath: "), "{said}");
804 assert!(!said.contains("--ns"), "{said}");
805 }
806
807 #[test]
808 fn a_binding_is_collected() {
809 let got = parse_namespace_bindings(&args(&[
810 "query", "-n", "m=urn:u", "//m:x",
811 ]))
812 .expect("valid");
813 assert_eq!(got, vec![("m".to_owned(), "urn:u".to_owned())]);
814 }
815
816 #[test]
817 fn the_long_form_works_too() {
818 let got = parse_namespace_bindings(&args(&["--ns", "m=urn:u"]))
819 .expect("valid");
820 assert_eq!(got.len(), 1);
821 }
822
823 #[test]
824 fn bindings_accumulate_and_later_ones_win() {
825 let got = parse_namespace_bindings(&args(&[
828 "-n",
829 "a=urn:one",
830 "-n",
831 "b=urn:two",
832 "-n",
833 "a=urn:three",
834 ]))
835 .expect("valid");
836 assert_eq!(
837 got,
838 vec![
839 ("b".to_owned(), "urn:two".to_owned()),
840 ("a".to_owned(), "urn:three".to_owned()),
841 ]
842 );
843 }
844
845 #[test]
846 fn a_uri_may_contain_an_equals_sign() {
847 let got = parse_namespace_bindings(&args(&["-n", "m=urn:u?a=b&c=d"]))
850 .expect("valid");
851 assert_eq!(got[0].1, "urn:u?a=b&c=d");
852 }
853
854 #[test]
855 fn an_empty_uri_is_allowed() {
856 let got =
859 parse_namespace_bindings(&args(&["-n", "m="])).expect("valid");
860 assert_eq!(got[0].1, "");
861 }
862
863 #[test]
864 fn a_binding_without_an_equals_sign_is_rejected() {
865 let err = parse_namespace_bindings(&args(&["-n", "bogus"]))
866 .expect_err("no `=`");
867 assert!(err.contains("PREFIX=URI"), "{err}");
868 }
869
870 #[test]
871 fn a_binding_needs_a_prefix() {
872 let err = parse_namespace_bindings(&args(&["-n", "=urn:u"]))
873 .expect_err("no prefix");
874 assert!(err.contains("prefix"), "{err}");
875 }
876
877 #[test]
878 fn the_xml_prefix_may_not_be_rebound() {
879 let err = parse_namespace_bindings(&args(&["-n", "xml=urn:u"]))
882 .expect_err("reserved");
883 assert!(err.contains("xml"), "{err}");
884 }
885
886 #[test]
887 fn a_trailing_flag_with_no_value_is_rejected() {
888 let err = parse_namespace_bindings(&args(&["query", "//x", "-n"]))
889 .expect_err("no value");
890 assert!(err.contains("PREFIX=URI"), "{err}");
891 }
892
893 #[test]
894 fn the_next_argument_is_taken_even_if_it_looks_like_a_flag() {
895 let err = parse_namespace_bindings(&args(&["-n", "-t", "//x"]))
899 .expect_err("flag as value");
900 assert!(err.contains("PREFIX=URI"), "{err}");
901 }
902
903 #[test]
904 fn no_bindings_is_not_an_error() {
905 let got =
906 parse_namespace_bindings(&args(&["query", "//x"])).expect("valid");
907 assert!(got.is_empty());
908 }
909}