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 std::fs::read_to_string(p).map_err(|e| format!("cannot read {p}: {e}"))
112 } else {
113 let mut buf = String::new();
114 let _ = std::io::stdin()
115 .read_to_string(&mut buf)
116 .map_err(|e| format!("cannot read stdin: {e}"))?;
117 Ok(buf)
118 }
119}
120
121fn parse_or_report(source: &str) -> Result<oxml::Document, String> {
122 oxml::parse(source).map_err(|e| {
123 let (line, col) = e.line_column(source);
124 format!("{line}:{col}: {e}")
125 })
126}
127
128fn positional_args(args: &[String]) -> Vec<&String> {
144 let mut out: Vec<&String> = Vec::new();
145 let mut skip_next = false;
146 for arg in args {
147 if skip_next {
148 skip_next = false;
149 continue;
150 }
151 if arg == "-n" || arg == "--ns" {
152 skip_next = true;
153 continue;
154 }
155 if !arg.starts_with('-') {
156 out.push(arg);
157 }
158 }
159 out
160}
161
162fn explain_xpath_error(error: &oxml::XPathError) -> String {
169 if error.message.contains("unbound namespace prefix") {
170 let prefix = error
171 .message
172 .split('`')
173 .nth(1)
174 .unwrap_or("PREFIX")
175 .to_owned();
176 format!(
177 "bad XPath: unbound namespace prefix `{prefix}`; \
178 bind it with --ns {prefix}=URI"
179 )
180 } else {
181 format!("bad XPath: {error}")
182 }
183}
184
185fn parse_namespace_bindings(
186 args: &[String],
187) -> Result<Vec<(String, String)>, String> {
188 let mut out: Vec<(String, String)> = Vec::new();
189 let mut expecting = false;
190 for arg in args {
191 if expecting {
192 expecting = false;
193 let Some((prefix, uri)) = arg.split_once('=') else {
194 return Err(format!(
195 "`{arg}` is not a namespace binding; write PREFIX=URI"
196 ));
197 };
198 if prefix.is_empty() {
199 return Err("a namespace binding needs a prefix".to_owned());
200 }
201 if prefix == "xml" {
202 return Err(
203 "`xml` is bound by the specification and may not be \
204 rebound"
205 .to_owned(),
206 );
207 }
208 out.retain(|(p, _)| p != prefix);
211 out.push((prefix.to_owned(), uri.to_owned()));
212 } else if arg == "-n" || arg == "--ns" {
213 expecting = true;
214 }
215 }
216 if expecting {
217 return Err("`--ns` needs a PREFIX=URI argument".to_owned());
218 }
219 Ok(out)
220}
221
222fn cmd_query(
223 source: &str,
224 expr: &str,
225 text_only: bool,
226 count_only: bool,
227 namespaces: &[(String, String)],
228 out: &mut dyn Write,
229) -> Result<ExitCode, String> {
230 let doc = parse_or_report(source)?;
231 let bindings: Vec<(&str, &str)> = namespaces
232 .iter()
233 .map(|(prefix, uri)| (prefix.as_str(), uri.as_str()))
234 .collect();
235 let xpath = oxml::XPath::compile_with_namespaces(expr, &bindings)
236 .map_err(|e| explain_xpath_error(&e))?;
237 let value = xpath.evaluate(&doc);
238
239 let Some(nodes) = value.nodes() else {
240 let _ = writeln!(out, "{}", value.to_str(&doc));
244 return Ok(ExitCode::SUCCESS);
245 };
246
247 if count_only {
248 let _ = writeln!(out, "{}", nodes.len());
249 return Ok(if nodes.is_empty() {
250 ExitCode::from(1)
251 } else {
252 ExitCode::SUCCESS
253 });
254 }
255
256 for &node in nodes {
257 let line = if text_only {
258 doc.text(node)
259 } else {
260 describe(&doc, node)
261 };
262 writeln!(out, "{line}").map_err(|e| e.to_string())?;
263 }
264
265 Ok(if nodes.is_empty() {
266 ExitCode::from(1)
267 } else {
268 ExitCode::SUCCESS
269 })
270}
271
272fn describe(doc: &oxml::Document, node: oxml::NodeId) -> String {
274 match doc.kind(node) {
275 Some(oxml::NodeKind::Element { .. }) => {
280 let Some(name) = doc.element_name(node) else {
281 return String::new();
282 };
283 let attrs: Vec<String> = doc
284 .attributes(node)
285 .iter()
286 .map(|a| {
287 let name =
288 doc.name(a.name).map_or("?", |n| n.local.as_str());
289 format!(" {name}=\"{}\"", a.value)
290 })
291 .collect();
292 let text = doc.text(node);
293 let preview = text.trim();
294 if preview.is_empty() {
295 format!("<{}{}/>", name.local, attrs.concat())
296 } else {
297 format!(
298 "<{}{}>{}</{}>",
299 name.local,
300 attrs.concat(),
301 truncate(preview, 60),
302 name.local
303 )
304 }
305 }
306 Some(oxml::NodeKind::Attr(a)) => {
307 let name = doc.name(a.name).map_or("?", |n| n.local.as_str());
308 format!("{name}=\"{}\"", a.value)
309 }
310 Some(oxml::NodeKind::Text(t)) => truncate(t.trim(), 80),
311 Some(oxml::NodeKind::Comment(c)) => {
312 format!("<!--{}-->", truncate(c.trim(), 60))
313 }
314 _ => String::new(),
315 }
316}
317
318fn truncate(s: &str, max: usize) -> String {
319 if s.chars().count() <= max {
320 return s.to_owned();
321 }
322 let head: String = s.chars().take(max).collect();
323 format!("{head}…")
324}
325
326fn cmd_validate(
327 source: &str,
328 xsd_path: &str,
329 out: &mut dyn Write,
330 err: &mut dyn Write,
331) -> Result<ExitCode, String> {
332 let xsd = std::fs::read_to_string(xsd_path)
333 .map_err(|e| format!("cannot read {xsd_path}: {e}"))?;
334 let schema = xmlschema::parse_schema(&xsd)
335 .map_err(|e| format!("bad schema: {e}"))?;
336 let doc = parse_or_report(source)?;
337 let report = xmlschema::validate(&doc, &schema);
338
339 if report.is_valid() {
340 let _ = writeln!(out, "valid");
341 return Ok(ExitCode::SUCCESS);
342 }
343 let _ = writeln!(err, "{} violation(s):", report.violations.len());
346 for v in &report.violations {
347 let _ = writeln!(err, " {} — {}", v.path, v.message);
348 }
349 Ok(ExitCode::from(1))
350}
351
352fn cmd_stats(source: &str, out: &mut dyn Write) -> Result<ExitCode, String> {
353 let doc = parse_or_report(source)?;
354 let mut elements = 0usize;
355 let mut attributes = 0usize;
356 let mut text = 0usize;
357 let mut comments = 0usize;
358 let mut namespaces = 0usize;
359 let mut others = 0usize;
360 let mut depth_max = 0usize;
361
362 for id in doc.descendants() {
363 match doc.kind(id) {
364 Some(oxml::NodeKind::Element { .. }) => {
365 elements += 1;
366 let mut d = 0usize;
367 let mut cur = Some(id);
368 while let Some(n) = cur {
369 cur = doc.parent(n);
370 d += 1;
371 }
372 depth_max = depth_max.max(d);
373 }
374 Some(oxml::NodeKind::Attr(_)) => attributes += 1,
375 Some(oxml::NodeKind::Text(_)) => text += 1,
376 Some(oxml::NodeKind::Comment(_)) => comments += 1,
377 Some(oxml::NodeKind::Namespace { .. }) => namespaces += 1,
378 _ => others += 1,
382 }
383 }
384
385 let _ = writeln!(out, "nodes {}", doc.len());
392 let _ = writeln!(out, "elements {elements}");
393 let _ = writeln!(out, "attributes {attributes}");
394 let _ = writeln!(out, "text {text}");
395 let _ = writeln!(out, "comments {comments}");
396 let _ = writeln!(out, "namespaces {namespaces}");
397 let _ = writeln!(out, "other {others}");
398 let _ = writeln!(out, "max depth {depth_max}");
399 debug_assert_eq!(
400 elements + attributes + text + comments + namespaces + others,
401 doc.len(),
402 "the breakdown must account for every node"
403 );
404 Ok(ExitCode::SUCCESS)
405}
406
407fn cmd_check(
410 source: &str,
411 out: &mut dyn Write,
412 err: &mut dyn Write,
413) -> ExitCode {
414 match oxml::parse(source) {
415 Ok(doc) => {
416 let _ = writeln!(out, "well-formed ({} nodes)", doc.len());
417 ExitCode::SUCCESS
418 }
419 Err(e) => {
420 let (line, col) = e.line_column(source);
421 let _ = writeln!(err, "not well-formed at {line}:{col}: {e}");
422 ExitCode::from(1)
423 }
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::parse_namespace_bindings;
430
431 fn args(list: &[&str]) -> Vec<String> {
432 list.iter().map(|s| (*s).to_owned()).collect()
433 }
434
435 struct Temp(std::path::PathBuf);
437
438 impl Temp {
439 fn new(name: &str, contents: &str) -> Self {
440 let path = std::env::temp_dir().join(name);
441 std::fs::write(&path, contents).expect("write scratch file");
442 Self(path)
443 }
444 fn path(&self) -> &str {
445 self.0.to_str().expect("utf-8 path")
446 }
447 }
448
449 impl Drop for Temp {
450 fn drop(&mut self) {
451 let _ = std::fs::remove_file(&self.0);
452 }
453 }
454
455 fn query(
462 doc: &str,
463 expr: &str,
464 text_only: bool,
465 count_only: bool,
466 ns: &[(String, String)],
467 ) -> Result<(String, String), String> {
468 let mut out = Vec::new();
469 let code =
470 super::cmd_query(doc, expr, text_only, count_only, ns, &mut out)?;
471 Ok((String::from_utf8(out).expect("utf-8"), format!("{code:?}")))
472 }
473
474 #[test]
475 fn cmd_query_reports_matches_and_their_absence() {
476 let doc = r"<r><t>one</t><t>two</t></r>";
477 let success = format!("{:?}", std::process::ExitCode::SUCCESS);
478
479 let (text, code) = query(doc, "//t", true, false, &[]).expect("valid");
480 assert_eq!(text, "one\ntwo\n");
481 assert_eq!(code, success);
482
483 let (text, code) = query(doc, "//t", false, true, &[]).expect("valid");
484 assert_eq!(text, "2\n");
485 assert_eq!(code, success);
486
487 let (text, code) =
489 query(doc, "count(//t)", false, false, &[]).expect("valid");
490 assert_eq!(text, "2\n");
491 assert_eq!(code, success);
492
493 let (text, code) =
498 query(doc, "//missing", true, false, &[]).expect("valid");
499 assert_eq!(text, "");
500 assert_eq!(code, format!("{:?}", std::process::ExitCode::from(1)));
501 }
502
503 #[test]
504 fn cmd_query_binds_namespaces_and_refuses_unbound_ones() {
505 let doc = r#"<r xmlns:m="urn:u"><m:t>ns</m:t></r>"#;
506 let bound = [(String::from("m"), String::from("urn:u"))];
507 let (text, _) =
508 query(doc, "//m:t", true, false, &bound).expect("bound");
509 assert_eq!(text, "ns\n");
510
511 let err = query(doc, "//m:t", true, false, &[]).expect_err("unbound");
512 assert!(err.contains("--ns"), "{err}");
513 }
514
515 #[test]
516 fn cmd_validate_reads_a_schema_from_disk() {
517 let schema = Temp::new(
518 "oxml-cli-test-schema.xsd",
519 r#"<?xml version="1.0"?>
520<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
521 <xs:element name="r"/>
522</xs:schema>"#,
523 );
524 let (mut out, mut err) = (Vec::new(), Vec::new());
525 assert!(
526 super::cmd_validate("<r/>", schema.path(), &mut out, &mut err)
527 .is_ok()
528 );
529 assert!(
532 super::cmd_validate("<r/>", "/nonexistent.xsd", &mut out, &mut err)
533 .is_err()
534 );
535 }
536
537 fn drive(argv: &[String]) -> Result<(String, String, String), String> {
539 let (mut out, mut err) = (Vec::new(), Vec::new());
540 let code = super::run(argv, &mut out, &mut err)?;
541 Ok((
542 String::from_utf8(out).expect("utf-8"),
543 String::from_utf8(err).expect("utf-8"),
544 format!("{code:?}"),
545 ))
546 }
547
548 #[test]
549 fn run_dispatches_each_command() {
550 let doc = Temp::new("oxml-cli-test-doc.xml", "<r><t>x</t></r>");
551 for command in [
552 vec!["check", doc.path()],
553 vec!["stats", doc.path()],
554 vec!["query", "//t", doc.path()],
555 vec!["query", "-t", "//t", doc.path()],
556 ] {
557 let owned = args(&command);
558 let (out, _, _) = drive(&owned).expect("{command:?}");
559 assert!(!out.is_empty(), "{command:?} printed nothing");
560 }
561 }
562
563 #[test]
564 fn run_answers_help_and_version_without_reading_anything() {
565 for flags in [vec!["--help"], vec!["-h"], vec!["--version"], vec!["-V"]]
568 {
569 let owned = args(&flags);
570 let (out, _, _) = drive(&owned).expect("{flags:?}");
571 assert!(!out.is_empty(), "{flags:?} printed nothing");
572 }
573 }
574
575 #[test]
576 fn run_rejects_a_command_it_does_not_have() {
577 let owned = args(&["frobnicate"]);
578 let err = drive(&owned).expect_err("unknown");
579 assert!(err.contains("unknown command"), "{err}");
580
581 let owned = args(&["query"]);
583 assert!(drive(&owned).is_err());
584 let owned = args(&["validate"]);
585 assert!(drive(&owned).is_err());
586 let owned: Vec<String> = Vec::new();
587 let (usage, _, _) = drive(&owned).expect("no arguments prints usage");
588 assert!(usage.contains("USAGE") || usage.contains("oxml"), "{usage}");
589 }
590
591 #[test]
592 fn truncate_keeps_short_strings_whole() {
593 assert_eq!(super::truncate("short", 10), "short");
594 assert_eq!(super::truncate("exactly10!", 10), "exactly10!");
595 }
596
597 #[test]
598 fn truncate_counts_characters_not_bytes() {
599 assert_eq!(super::truncate("ééééé", 3), "ééé…");
602 assert_eq!(super::truncate("ééééé", 5), "ééééé");
603 }
604
605 #[test]
606 fn describe_renders_each_node_kind() {
607 let doc =
608 super::parse_or_report(r#"<r a="1"><!--note--><?pi go?>text</r>"#)
609 .expect("well-formed");
610 let rendered: Vec<String> = doc
611 .descendants()
612 .map(|id| super::describe(&doc, id))
613 .collect();
614 let all = rendered.join("\n");
615 assert!(
616 all.contains("<r a=\"1\">"),
617 "element with attributes: {all}"
618 );
619 assert!(all.contains("note"), "comment: {all}");
620 assert!(
624 rendered.iter().any(String::is_empty),
625 "a processing instruction renders empty: {rendered:?}"
626 );
627 assert!(all.contains("text"), "text: {all}");
628 }
629
630 #[test]
631 fn parse_or_report_explains_where_the_document_broke() {
632 let err = super::parse_or_report("<a></b>").expect_err("mismatched");
633 assert!(err.contains("1:4"), "{err}");
636 }
637
638 #[test]
639 fn cmd_check_separates_a_bad_document_from_a_good_one() {
640 let (mut out, mut err) = (Vec::new(), Vec::new());
641 assert_eq!(
642 format!("{:?}", super::cmd_check("<a/>", &mut out, &mut err)),
643 format!("{:?}", std::process::ExitCode::SUCCESS)
644 );
645 assert!(
646 String::from_utf8(out)
647 .expect("utf-8")
648 .contains("well-formed"),
649 "a good document says so"
650 );
651
652 let (mut out, mut err) = (Vec::new(), Vec::new());
655 assert_eq!(
656 format!("{:?}", super::cmd_check("<a>", &mut out, &mut err)),
657 format!("{:?}", std::process::ExitCode::from(1))
658 );
659 assert!(out.is_empty(), "a failure printed to stdout");
662 assert!(
663 String::from_utf8(err)
664 .expect("utf-8")
665 .contains("not well-formed"),
666 "a bad document says why"
667 );
668 }
669
670 #[test]
671 fn cmd_stats_counts_a_document_without_touching_the_filesystem() {
672 let mut out = Vec::new();
673 assert!(
674 super::cmd_stats("<r><a/><b>x</b><!--c--></r>", &mut out).is_ok()
675 );
676 let text = String::from_utf8(out).expect("utf-8");
677 assert!(text.contains("elements 3"), "{text}");
678 assert!(text.contains("comments 1"), "{text}");
679 let mut out = Vec::new();
681 assert!(super::cmd_stats("<r>", &mut out).is_err());
682 }
683
684 #[test]
685 fn cmd_stats_accounts_for_every_node() {
686 let doc = r#"<r xmlns:m="urn:u"><m:a k="v">x</m:a><!--c--></r>"#;
691 let mut out = Vec::new();
692 let _ = super::cmd_stats(doc, &mut out).expect("well-formed");
693 let text = String::from_utf8(out).expect("utf-8");
694
695 let value = |label: &str| -> usize {
696 text.lines()
697 .find(|l| l.starts_with(label))
698 .and_then(|l| l.split_whitespace().last())
699 .and_then(|n| n.parse().ok())
700 .unwrap_or_else(|| panic!("no `{label}` line in {text}"))
701 };
702 let total = value("nodes");
703 let parts = [
704 "elements",
705 "attributes",
706 "text",
707 "comments",
708 "namespaces",
709 "other",
710 ]
711 .iter()
712 .map(|l| value(l))
713 .sum::<usize>();
714 assert_eq!(parts, total, "the breakdown does not add up:\n{text}");
715 assert_eq!(value("namespaces"), 2, "{text}");
718 }
719
720 #[test]
721 fn an_option_value_is_not_a_positional_argument() {
722 let owned = args(&["query", "-n", "m=urn:example", "//m:x", "f.xml"]);
726 let got = super::positional_args(&owned);
727 let got: Vec<&str> = got.iter().map(|s| s.as_str()).collect();
728 assert_eq!(got, ["query", "//m:x", "f.xml"]);
729 }
730
731 #[test]
732 fn ordinary_flags_are_still_dropped() {
733 let owned = args(&["query", "-t", "//x", "-c", "f.xml"]);
734 let got = super::positional_args(&owned);
735 let got: Vec<&str> = got.iter().map(|s| s.as_str()).collect();
736 assert_eq!(got, ["query", "//x", "f.xml"]);
737 }
738
739 #[test]
740 fn a_trailing_option_consumes_nothing_that_is_not_there() {
741 let owned = args(&["query", "//x", "-n"]);
742 let got = super::positional_args(&owned);
743 let got: Vec<&str> = got.iter().map(|s| s.as_str()).collect();
744 assert_eq!(got, ["query", "//x"]);
745 }
746
747 #[test]
748 fn the_long_option_form_also_takes_a_value() {
749 let owned = args(&["query", "--ns", "m=urn:u", "//m:x"]);
750 let got = super::positional_args(&owned);
751 let got: Vec<&str> = got.iter().map(|s| s.as_str()).collect();
752 assert_eq!(got, ["query", "//m:x"]);
753 }
754
755 #[test]
756 fn an_unbound_prefix_is_explained_in_terms_of_the_flag() {
757 let err = oxml::XPath::compile("//m:item").expect_err("unbound");
761 let said = super::explain_xpath_error(&err);
762 assert!(said.contains("--ns m=URI"), "{said}");
763 assert!(!said.contains("compile_with_namespaces"), "{said}");
764 }
765
766 #[test]
767 fn other_expression_errors_are_passed_through() {
768 let err = oxml::XPath::compile("//[").expect_err("malformed");
771 let said = super::explain_xpath_error(&err);
772 assert!(said.starts_with("bad XPath: "), "{said}");
773 assert!(!said.contains("--ns"), "{said}");
774 }
775
776 #[test]
777 fn a_binding_is_collected() {
778 let got = parse_namespace_bindings(&args(&[
779 "query", "-n", "m=urn:u", "//m:x",
780 ]))
781 .expect("valid");
782 assert_eq!(got, vec![("m".to_owned(), "urn:u".to_owned())]);
783 }
784
785 #[test]
786 fn the_long_form_works_too() {
787 let got = parse_namespace_bindings(&args(&["--ns", "m=urn:u"]))
788 .expect("valid");
789 assert_eq!(got.len(), 1);
790 }
791
792 #[test]
793 fn bindings_accumulate_and_later_ones_win() {
794 let got = parse_namespace_bindings(&args(&[
797 "-n",
798 "a=urn:one",
799 "-n",
800 "b=urn:two",
801 "-n",
802 "a=urn:three",
803 ]))
804 .expect("valid");
805 assert_eq!(
806 got,
807 vec![
808 ("b".to_owned(), "urn:two".to_owned()),
809 ("a".to_owned(), "urn:three".to_owned()),
810 ]
811 );
812 }
813
814 #[test]
815 fn a_uri_may_contain_an_equals_sign() {
816 let got = parse_namespace_bindings(&args(&["-n", "m=urn:u?a=b&c=d"]))
819 .expect("valid");
820 assert_eq!(got[0].1, "urn:u?a=b&c=d");
821 }
822
823 #[test]
824 fn an_empty_uri_is_allowed() {
825 let got =
828 parse_namespace_bindings(&args(&["-n", "m="])).expect("valid");
829 assert_eq!(got[0].1, "");
830 }
831
832 #[test]
833 fn a_binding_without_an_equals_sign_is_rejected() {
834 let err = parse_namespace_bindings(&args(&["-n", "bogus"]))
835 .expect_err("no `=`");
836 assert!(err.contains("PREFIX=URI"), "{err}");
837 }
838
839 #[test]
840 fn a_binding_needs_a_prefix() {
841 let err = parse_namespace_bindings(&args(&["-n", "=urn:u"]))
842 .expect_err("no prefix");
843 assert!(err.contains("prefix"), "{err}");
844 }
845
846 #[test]
847 fn the_xml_prefix_may_not_be_rebound() {
848 let err = parse_namespace_bindings(&args(&["-n", "xml=urn:u"]))
851 .expect_err("reserved");
852 assert!(err.contains("xml"), "{err}");
853 }
854
855 #[test]
856 fn a_trailing_flag_with_no_value_is_rejected() {
857 let err = parse_namespace_bindings(&args(&["query", "//x", "-n"]))
858 .expect_err("no value");
859 assert!(err.contains("PREFIX=URI"), "{err}");
860 }
861
862 #[test]
863 fn the_next_argument_is_taken_even_if_it_looks_like_a_flag() {
864 let err = parse_namespace_bindings(&args(&["-n", "-t", "//x"]))
868 .expect_err("flag as value");
869 assert!(err.contains("PREFIX=URI"), "{err}");
870 }
871
872 #[test]
873 fn no_bindings_is_not_an_error() {
874 let got =
875 parse_namespace_bindings(&args(&["query", "//x"])).expect("valid");
876 assert!(got.is_empty());
877 }
878}