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        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
142/// Collect `-n PREFIX=URI` bindings, in order.
143///
144/// From oxml 0.0.4 a prefix in an expression resolves against bindings
145/// supplied with the query rather than against the document, and an
146/// unbound prefix is a compile error. Without a way to pass them,
147/// `//m:item` would have become unanswerable from the command line --
148/// a previously-wrong answer turning into an error with no remedy.
149///
150/// A malformed binding is a *usage* error, exit 2, not a query failure:
151/// the user made a mistake rather than the document.
152/// The positional arguments, with option values removed.
153///
154/// `-n` takes a value, so the argument after it is not positional.
155/// Filtering on a leading `-` alone would have made `urn:example` in
156/// `-n m=urn:example` look like a command.
157fn 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
176/// Turn an `XPath` compile error into something a shell user can act on.
177///
178/// The library's unbound-prefix message names
179/// `XPath::compile_with_namespaces`, which is no help to someone at a
180/// prompt. Everything else is passed through: those messages already
181/// say where in the expression the problem is.
182fn 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            // Later bindings win, so a wrapper script can set defaults
223            // a caller overrides.
224            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        // A non-node-set result — a count, a string, a boolean — is
273        // printed as-is. `oxml query 'count(//x)'` should behave like
274        // the expression it is.
275        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
304/// A one-line summary of a node, for when `--text` is not enough.
305fn describe(doc: &oxml::Document, node: oxml::NodeId) -> String {
306    match doc.kind(node) {
307        // Resolved through `element_name` rather than destructured from
308        // the variant: the element's name is interned, so the variant
309        // holds a handle rather than the name itself. This accessor is
310        // stable across that change.
311        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    // Violations go to stderr so `oxml validate` can be used in a
375    // pipeline without the diagnostics contaminating the output.
376    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            // The root, and processing instructions. Reported as a
410            // remainder so the lines below add up to `nodes` -- see
411            // the note there.
412            _ => others += 1,
413        }
414    }
415
416    // Every kind is printed, including the ones a reader would not
417    // think to ask for. The counts previously stopped at comments,
418    // which left `nodes` two larger than the lines beneath it on any
419    // document with a namespace -- an apparent arithmetic error in
420    // the tool, and one that stood in the sample document shipped
421    // with the examples.
422    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
438/// Infallible: a parse failure is a *result* here, not an error — the
439/// whole point of `check` is to report it and exit non-zero.
440fn 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    /// A scratch file, removed when the test ends.
467    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    /// Run a query and return what it printed alongside its code.
487    ///
488    /// Before the library target existed these assertions could only
489    /// reach `is_ok()`: the output went straight to the process's
490    /// stdout, so a query that exited 0 having printed the wrong
491    /// answer passed.
492    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        // A non-node-set result prints as itself.
519        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        // Exit 1 when nothing matched, so `if oxml query …` reads as a
525        // question about the document -- and nothing is printed, so a
526        // caller piping the output sees an empty result, not a blank
527        // line.
528        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        // A schema that is not there is a usage error, not a document
561        // that failed to validate.
562        assert!(
563            super::cmd_validate("<r/>", "/nonexistent.xsd", &mut out, &mut err)
564                .is_err()
565        );
566    }
567
568    /// Drive `run` and hand back what each stream received.
569    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        // These must not touch stdin: `oxml --help` in a terminal with
597        // no input would otherwise hang.
598        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        // And a command that needs an argument it was not given.
613        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        // Cutting at a byte offset would split a multi-byte character
631        // and panic. Five accented characters are ten bytes.
632        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        // A processing instruction falls to the catch-all arm and
652        // renders as nothing. Worth pinning: a summary that silently
653        // omits a node kind is easy to grow and hard to notice.
654        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        // A line and column, because a byte offset sends the reader
665        // counting.
666        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        // Exit 1: the document has a problem. Exit 2 is reserved for
684        // the caller having made a mistake.
685        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        // The diagnosis goes to stderr, so `oxml check x.xml > out`
691        // still shows the caller what was wrong.
692        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        // A malformed document is reported rather than counted.
711        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        // The kind lines must sum to `nodes`. They did not before
718        // 0.0.7: namespace nodes and the document root were counted
719        // in the total and printed nowhere, so `stats` on any
720        // namespaced document looked as though it could not add up.
721        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        // And the namespaces are really counted: `m`, plus the
747        // implicit `xml` binding every document carries.
748        assert_eq!(value("namespaces"), 2, "{text}");
749    }
750
751    #[test]
752    fn an_option_value_is_not_a_positional_argument() {
753        // Filtering on a leading `-` alone would have made
754        // `urn:example` look like a command, because a URI does not
755        // begin with a dash.
756        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        // The library says "bind it with
789        // XPath::compile_with_namespaces", which is a Rust function.
790        // Someone at a shell needs `--ns`.
791        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        // Those messages already say where in the expression the
800        // problem is; rewriting them would lose that.
801        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        // Later wins, so a wrapper script can set defaults a caller
826        // overrides on the same command line.
827        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        // Split on the *first* `=`; a URI with a query string is
848        // ordinary and must not be truncated.
849        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        // Binding a prefix to the empty string is unusual but not a
857        // usage error; the library decides what it means.
858        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        // Bound by the specification. Rebinding it is not something a
880        // document can do either.
881        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        // `-n -t` is a mistake worth reporting rather than silently
896        // treating `-t` as a separate option and consuming the
897        // argument after it.
898        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}