Skip to main content

oxml_cli/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 oxml. All rights reserved.
3
4//! `oxml` — query, validate and inspect XML from the command line.
5//!
6//! Argument parsing is hand-rolled rather than pulled from a crate.
7//! The surface is four subcommands and a handful of flags; a
8//! dependency would be more code to audit than it removes, and this
9//! binary is meant to be cheap to trust.
10
11#![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
45/// Run the CLI over `args`, writing to `out` and `err`.
46///
47/// Both streams are parameters rather than `println!` so that a test
48/// or a benchmark can drive the real command path and read back what
49/// it produced. Going through the binary instead measures process
50/// spawn and pipe setup alongside the work, and cannot assert on
51/// output without a subprocess.
52///
53/// # Errors
54///
55/// Returns the message the binary prints after `oxml: `, for usage
56/// mistakes and unreadable inputs. A document that is merely invalid
57/// is not an error here -- it is a non-zero [`ExitCode`].
58pub 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
108/// Read from a path, or standard input when none is given.
109fn 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
128/// Collect `-n PREFIX=URI` bindings, in order.
129///
130/// From oxml 0.0.4 a prefix in an expression resolves against bindings
131/// supplied with the query rather than against the document, and an
132/// unbound prefix is a compile error. Without a way to pass them,
133/// `//m:item` would have become unanswerable from the command line --
134/// a previously-wrong answer turning into an error with no remedy.
135///
136/// A malformed binding is a *usage* error, exit 2, not a query failure:
137/// the user made a mistake rather than the document.
138/// The positional arguments, with option values removed.
139///
140/// `-n` takes a value, so the argument after it is not positional.
141/// Filtering on a leading `-` alone would have made `urn:example` in
142/// `-n m=urn:example` look like a command.
143fn 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
162/// Turn an `XPath` compile error into something a shell user can act on.
163///
164/// The library's unbound-prefix message names
165/// `XPath::compile_with_namespaces`, which is no help to someone at a
166/// prompt. Everything else is passed through: those messages already
167/// say where in the expression the problem is.
168fn 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            // Later bindings win, so a wrapper script can set defaults
209            // a caller overrides.
210            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        // A non-node-set result — a count, a string, a boolean — is
241        // printed as-is. `oxml query 'count(//x)'` should behave like
242        // the expression it is.
243        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
272/// A one-line summary of a node, for when `--text` is not enough.
273fn describe(doc: &oxml::Document, node: oxml::NodeId) -> String {
274    match doc.kind(node) {
275        // Resolved through `element_name` rather than destructured from
276        // the variant: the element's name is interned, so the variant
277        // holds a handle rather than the name itself. This accessor is
278        // stable across that change.
279        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    // Violations go to stderr so `oxml validate` can be used in a
344    // pipeline without the diagnostics contaminating the output.
345    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            // The root, and processing instructions. Reported as a
379            // remainder so the lines below add up to `nodes` -- see
380            // the note there.
381            _ => others += 1,
382        }
383    }
384
385    // Every kind is printed, including the ones a reader would not
386    // think to ask for. The counts previously stopped at comments,
387    // which left `nodes` two larger than the lines beneath it on any
388    // document with a namespace -- an apparent arithmetic error in
389    // the tool, and one that stood in the sample document shipped
390    // with the examples.
391    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
407/// Infallible: a parse failure is a *result* here, not an error — the
408/// whole point of `check` is to report it and exit non-zero.
409fn 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    /// A scratch file, removed when the test ends.
436    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    /// Run a query and return what it printed alongside its code.
456    ///
457    /// Before the library target existed these assertions could only
458    /// reach `is_ok()`: the output went straight to the process's
459    /// stdout, so a query that exited 0 having printed the wrong
460    /// answer passed.
461    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        // A non-node-set result prints as itself.
488        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        // Exit 1 when nothing matched, so `if oxml query …` reads as a
494        // question about the document -- and nothing is printed, so a
495        // caller piping the output sees an empty result, not a blank
496        // line.
497        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        // A schema that is not there is a usage error, not a document
530        // that failed to validate.
531        assert!(
532            super::cmd_validate("<r/>", "/nonexistent.xsd", &mut out, &mut err)
533                .is_err()
534        );
535    }
536
537    /// Drive `run` and hand back what each stream received.
538    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        // These must not touch stdin: `oxml --help` in a terminal with
566        // no input would otherwise hang.
567        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        // And a command that needs an argument it was not given.
582        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        // Cutting at a byte offset would split a multi-byte character
600        // and panic. Five accented characters are ten bytes.
601        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        // A processing instruction falls to the catch-all arm and
621        // renders as nothing. Worth pinning: a summary that silently
622        // omits a node kind is easy to grow and hard to notice.
623        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        // A line and column, because a byte offset sends the reader
634        // counting.
635        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        // Exit 1: the document has a problem. Exit 2 is reserved for
653        // the caller having made a mistake.
654        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        // The diagnosis goes to stderr, so `oxml check x.xml > out`
660        // still shows the caller what was wrong.
661        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        // A malformed document is reported rather than counted.
680        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        // The kind lines must sum to `nodes`. They did not before
687        // 0.0.7: namespace nodes and the document root were counted
688        // in the total and printed nowhere, so `stats` on any
689        // namespaced document looked as though it could not add up.
690        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        // And the namespaces are really counted: `m`, plus the
716        // implicit `xml` binding every document carries.
717        assert_eq!(value("namespaces"), 2, "{text}");
718    }
719
720    #[test]
721    fn an_option_value_is_not_a_positional_argument() {
722        // Filtering on a leading `-` alone would have made
723        // `urn:example` look like a command, because a URI does not
724        // begin with a dash.
725        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        // The library says "bind it with
758        // XPath::compile_with_namespaces", which is a Rust function.
759        // Someone at a shell needs `--ns`.
760        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        // Those messages already say where in the expression the
769        // problem is; rewriting them would lose that.
770        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        // Later wins, so a wrapper script can set defaults a caller
795        // overrides on the same command line.
796        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        // Split on the *first* `=`; a URI with a query string is
817        // ordinary and must not be truncated.
818        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        // Binding a prefix to the empty string is unusual but not a
826        // usage error; the library decides what it means.
827        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        // Bound by the specification. Rebinding it is not something a
849        // document can do either.
850        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        // `-n -t` is a mistake worth reporting rather than silently
865        // treating `-t` as a separate option and consuming the
866        // argument after it.
867        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}