Skip to main content

facet_xml_node/
lib.rs

1//! Raw XML element types and deserialization from Element trees.
2
3mod parser;
4
5use facet_xml as xml;
6use std::collections::HashMap;
7
8pub use parser::{
9    ElementParseError, ElementParser, ElementSerializeError, ElementSerializer, from_element,
10    to_element,
11};
12
13/// Error when navigating to a path in an Element tree.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum PathError {
16    /// Path was empty - cannot navigate to root as Content.
17    EmptyPath { path: Vec<usize> },
18    /// Index out of bounds.
19    IndexOutOfBounds {
20        path: Vec<usize>,
21        index: usize,
22        len: usize,
23    },
24    /// Tried to navigate through a text node.
25    TextNodeHasNoChildren { path: Vec<usize> },
26}
27
28impl std::fmt::Display for PathError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            PathError::EmptyPath { path } => write!(f, "empty path: {path:?}"),
32            PathError::IndexOutOfBounds { path, index, len } => {
33                write!(
34                    f,
35                    "index {index} out of bounds (len={len}) at path {path:?}"
36                )
37            }
38            PathError::TextNodeHasNoChildren { path } => {
39                write!(f, "text node has no children at path {path:?}")
40            }
41        }
42    }
43}
44
45impl std::error::Error for PathError {}
46
47/// Content that can appear inside an XML element - either child elements or text.
48#[derive(Debug, Clone, PartialEq, Eq, facet::Facet)]
49#[repr(u8)]
50pub enum Content {
51    /// Text content.
52    #[facet(xml::text)]
53    Text(String),
54    /// A child element (catch-all for any tag name).
55    #[facet(xml::custom_element)]
56    Element(Element),
57}
58
59impl Content {
60    /// Returns `Some(&str)` if this is text content.
61    pub fn as_text(&self) -> Option<&str> {
62        match self {
63            Content::Text(t) => Some(t),
64            _ => None,
65        }
66    }
67
68    /// Returns `Some(&Element)` if this is an element.
69    pub fn as_element(&self) -> Option<&Element> {
70        match self {
71            Content::Element(e) => Some(e),
72            _ => None,
73        }
74    }
75}
76
77/// An XML element that captures any tag name, attributes, and children.
78///
79/// This type can represent arbitrary XML structure without needing
80/// a predefined schema.
81#[derive(Debug, Clone, PartialEq, Eq, Default, facet::Facet)]
82pub struct Element {
83    /// The element's tag name (captured dynamically).
84    #[facet(xml::tag, default)]
85    pub tag: String,
86
87    /// All attributes as key-value pairs.
88    #[facet(flatten, default)]
89    pub attrs: HashMap<String, String>,
90
91    /// Child content (elements and text).
92    #[facet(flatten, default)]
93    #[facet(recursive_type)]
94    pub children: Vec<Content>,
95}
96
97impl Element {
98    /// Create a new element with just a tag name.
99    pub fn new(tag: impl Into<String>) -> Self {
100        Self {
101            tag: tag.into(),
102            attrs: HashMap::new(),
103            children: Vec::new(),
104        }
105    }
106
107    /// Add an attribute.
108    pub fn with_attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
109        self.attrs.insert(name.into(), value.into());
110        self
111    }
112
113    /// Add a child element.
114    pub fn with_child(mut self, child: Element) -> Self {
115        self.children.push(Content::Element(child));
116        self
117    }
118
119    /// Add text content.
120    pub fn with_text(mut self, text: impl Into<String>) -> Self {
121        self.children.push(Content::Text(text.into()));
122        self
123    }
124
125    /// Get an attribute value by name.
126    pub fn get_attr(&self, name: &str) -> Option<&str> {
127        self.attrs.get(name).map(|s| s.as_str())
128    }
129
130    /// Iterate over child elements (skipping text nodes).
131    pub fn child_elements(&self) -> impl Iterator<Item = &Element> {
132        self.children.iter().filter_map(|c| c.as_element())
133    }
134
135    /// Get the combined text content (concatenated from all text children).
136    pub fn text_content(&self) -> String {
137        let mut result = String::new();
138        for child in &self.children {
139            match child {
140                Content::Text(t) => result.push_str(t),
141                Content::Element(e) => result.push_str(&e.text_content()),
142            }
143        }
144        result
145    }
146
147    /// Get a mutable reference to content at a path.
148    /// Path is a sequence of child indices.
149    pub fn get_content_mut(&mut self, path: &[usize]) -> Result<&mut Content, PathError> {
150        if path.is_empty() {
151            return Err(PathError::EmptyPath { path: vec![] });
152        }
153
154        let idx = path[0];
155        let len = self.children.len();
156        let child = self
157            .children
158            .get_mut(idx)
159            .ok_or_else(|| PathError::IndexOutOfBounds {
160                path: path.to_vec(),
161                index: idx,
162                len,
163            })?;
164
165        if path.len() == 1 {
166            return Ok(child);
167        }
168
169        match child {
170            Content::Element(e) => e.get_content_mut(&path[1..]),
171            Content::Text(_) => Err(PathError::TextNodeHasNoChildren {
172                path: path.to_vec(),
173            }),
174        }
175    }
176
177    /// Get a mutable reference to the children vec at a path.
178    pub fn children_mut(&mut self, path: &[usize]) -> Result<&mut Vec<Content>, PathError> {
179        if path.is_empty() {
180            return Ok(&mut self.children);
181        }
182        match self.get_content_mut(path)? {
183            Content::Element(e) => Ok(&mut e.children),
184            Content::Text(_) => Err(PathError::TextNodeHasNoChildren {
185                path: path.to_vec(),
186            }),
187        }
188    }
189
190    /// Get a mutable reference to the attrs at a path.
191    pub fn attrs_mut(&mut self, path: &[usize]) -> Result<&mut HashMap<String, String>, PathError> {
192        if path.is_empty() {
193            return Ok(&mut self.attrs);
194        }
195        match self.get_content_mut(path)? {
196            Content::Element(e) => Ok(&mut e.attrs),
197            Content::Text(_) => Err(PathError::TextNodeHasNoChildren {
198                path: path.to_vec(),
199            }),
200        }
201    }
202
203    /// Serialize to HTML string.
204    pub fn to_html(&self) -> String {
205        let mut out = String::new();
206        self.write_html(&mut out);
207        out
208    }
209
210    /// Write HTML to a string buffer.
211    pub fn write_html(&self, out: &mut String) {
212        out.push('<');
213        out.push_str(&self.tag);
214        // Sort attrs for deterministic output
215        let mut attr_list: Vec<_> = self.attrs.iter().collect();
216        attr_list.sort_by_key(|(k, _)| *k);
217        for (k, v) in attr_list {
218            out.push(' ');
219            out.push_str(k);
220            out.push_str("=\"");
221            out.push_str(&html_escape(v));
222            out.push('"');
223        }
224        out.push('>');
225        for child in &self.children {
226            match child {
227                Content::Text(s) => out.push_str(s),
228                Content::Element(e) => e.write_html(out),
229            }
230        }
231        out.push_str("</");
232        out.push_str(&self.tag);
233        out.push('>');
234    }
235}
236
237fn html_escape(s: &str) -> String {
238    s.replace('&', "&amp;")
239        .replace('<', "&lt;")
240        .replace('>', "&gt;")
241        .replace('"', "&quot;")
242}
243
244impl From<Element> for Content {
245    fn from(e: Element) -> Self {
246        Content::Element(e)
247    }
248}
249
250impl From<String> for Content {
251    fn from(s: String) -> Self {
252        Content::Text(s)
253    }
254}
255
256impl From<&str> for Content {
257    fn from(s: &str) -> Self {
258        Content::Text(s.to_owned())
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use std::{fmt::Display, str::FromStr};
265
266    use super::*;
267    use facet::Facet;
268    use facet_testhelpers::test;
269
270    #[test]
271    fn element_builder_api() {
272        let elem = Element::new("root")
273            .with_attr("id", "123")
274            .with_child(Element::new("child").with_text("hello world"));
275
276        assert_eq!(elem.tag, "root");
277        assert_eq!(elem.get_attr("id"), Some("123"));
278        assert_eq!(elem.children.len(), 1);
279
280        let child = elem.child_elements().next().unwrap();
281        assert_eq!(child.tag, "child");
282        assert_eq!(child.text_content(), "hello world");
283    }
284
285    #[test]
286    fn parse_simple_xml() {
287        let xml = r#"<root><child>hello</child></root>"#;
288        let elem: Element = facet_xml::from_str(xml).unwrap();
289
290        assert_eq!(elem.tag, "root");
291        assert_eq!(elem.children.len(), 1);
292
293        let child = elem.child_elements().next().unwrap();
294        assert_eq!(child.tag, "child");
295        assert_eq!(child.text_content(), "hello");
296    }
297
298    #[test]
299    fn parse_with_attributes() {
300        let xml = r#"<root id="123" class="test"><child name="foo">bar</child></root>"#;
301        let elem: Element = facet_xml::from_str(xml).unwrap();
302
303        assert_eq!(elem.tag, "root");
304        assert_eq!(elem.get_attr("id"), Some("123"));
305        assert_eq!(elem.get_attr("class"), Some("test"));
306
307        let child = elem.child_elements().next().unwrap();
308        assert_eq!(child.get_attr("name"), Some("foo"));
309        assert_eq!(child.text_content(), "bar");
310    }
311
312    #[test]
313    fn parse_mixed_content() {
314        let xml = r#"<p>Hello <b>world</b>!</p>"#;
315        let elem: Element = facet_xml::from_str(xml).unwrap();
316
317        assert_eq!(elem.tag, "p");
318        assert_eq!(elem.children.len(), 3);
319        // Note: trailing whitespace is trimmed by XML parser
320        assert_eq!(elem.children[0].as_text(), Some("Hello"));
321        assert_eq!(elem.children[1].as_element().unwrap().tag, "b");
322        assert_eq!(elem.children[2].as_text(), Some("!"));
323        assert_eq!(elem.text_content(), "Helloworld!");
324    }
325
326    #[test]
327    fn from_element_to_struct() {
328        #[derive(facet::Facet, Debug, PartialEq)]
329        struct Person {
330            name: String,
331            age: u32,
332        }
333
334        let elem = Element::new("person")
335            .with_child(Element::new("name").with_text("Alice"))
336            .with_child(Element::new("age").with_text("30"));
337
338        let person: Person = from_element(&elem).unwrap();
339        assert_eq!(person.name, "Alice");
340        assert_eq!(person.age, 30);
341    }
342
343    #[test]
344    fn from_element_with_attrs() {
345        #[derive(facet::Facet, Debug, PartialEq)]
346        struct Item {
347            #[facet(xml::attribute)]
348            id: String,
349            value: String,
350        }
351
352        let elem = Element::new("item")
353            .with_attr("id", "123")
354            .with_child(Element::new("value").with_text("hello"));
355
356        let item: Item = from_element(&elem).unwrap();
357        assert_eq!(item.id, "123");
358        assert_eq!(item.value, "hello");
359    }
360
361    #[test]
362    fn to_element_simple() {
363        #[derive(facet::Facet, Debug, PartialEq)]
364        struct Person {
365            name: String,
366            age: u32,
367        }
368
369        let person = Person {
370            name: "Alice".to_string(),
371            age: 30,
372        };
373
374        let elem = to_element(&person).unwrap();
375        assert_eq!(elem.tag, "person");
376        assert_eq!(elem.children.len(), 2);
377
378        let name_child = elem.child_elements().find(|e| e.tag == "name").unwrap();
379        assert_eq!(name_child.text_content(), "Alice");
380
381        let age_child = elem.child_elements().find(|e| e.tag == "age").unwrap();
382        assert_eq!(age_child.text_content(), "30");
383    }
384
385    #[test]
386    fn to_element_with_attrs() {
387        #[derive(facet::Facet, Debug, PartialEq)]
388        struct Item {
389            #[facet(xml::attribute)]
390            id: String,
391            value: String,
392        }
393
394        let item = Item {
395            id: "123".to_string(),
396            value: "hello".to_string(),
397        };
398
399        let elem = to_element(&item).unwrap();
400        assert_eq!(elem.tag, "item");
401        assert_eq!(elem.get_attr("id"), Some("123"));
402
403        let value_child = elem.child_elements().find(|e| e.tag == "value").unwrap();
404        assert_eq!(value_child.text_content(), "hello");
405    }
406
407    #[test]
408    fn roundtrip_simple() {
409        #[derive(facet::Facet, Debug, PartialEq)]
410        struct Person {
411            name: String,
412            age: u32,
413        }
414
415        let original = Person {
416            name: "Bob".to_string(),
417            age: 42,
418        };
419
420        let elem = to_element(&original).unwrap();
421        let roundtripped: Person = from_element(&elem).unwrap();
422
423        assert_eq!(original, roundtripped);
424    }
425
426    #[test]
427    fn roundtrip_with_attrs() {
428        #[derive(facet::Facet, Debug, PartialEq)]
429        struct Item {
430            #[facet(xml::attribute)]
431            id: String,
432            #[facet(xml::attribute)]
433            version: u32,
434            value: String,
435        }
436
437        let original = Item {
438            id: "test-123".to_string(),
439            version: 5,
440            value: "content".to_string(),
441        };
442
443        let elem = to_element(&original).unwrap();
444        let roundtripped: Item = from_element(&elem).unwrap();
445
446        assert_eq!(original, roundtripped);
447    }
448
449    /// Reproduction test for issue #10:
450    /// `Vec<Element>` does not match any tag, although it should match every tag
451    #[test]
452    fn vec_element_matches_any_tag() {
453        #[derive(facet::Facet, Debug)]
454        #[facet(rename = "any")]
455        struct AnyContainer {
456            #[facet(xml::elements)]
457            elements: Vec<Element>,
458        }
459
460        let xml = r#"<any><foo a="b" /><bar c="d" /></any>"#;
461        let result: AnyContainer = facet_xml::from_str(xml).unwrap();
462
463        assert_eq!(result.elements.len(), 2);
464        assert_eq!(result.elements[0].tag, "foo");
465        assert_eq!(result.elements[0].get_attr("a"), Some("b"));
466        assert_eq!(result.elements[1].tag, "bar");
467        assert_eq!(result.elements[1].get_attr("c"), Some("d"));
468    }
469
470    /// Edge case: specific fields should take precedence over catch-all Vec<Element>
471    #[test]
472    fn vec_element_catch_all_with_specific_field() {
473        #[derive(facet::Facet, Debug)]
474        #[facet(rename = "container")]
475        struct MixedContainer {
476            // Specific field - should match <name> elements
477            name: String,
478            // Catch-all - should get everything else
479            #[facet(xml::elements)]
480            others: Vec<Element>,
481        }
482
483        let xml = r#"<container><name>test</name><foo>a</foo><bar>b</bar></container>"#;
484        let result: MixedContainer = facet_xml::from_str(xml).unwrap();
485
486        assert_eq!(result.name, "test");
487        assert_eq!(result.others.len(), 2);
488        assert_eq!(result.others[0].tag, "foo");
489        assert_eq!(result.others[1].tag, "bar");
490    }
491
492    /// Edge case: text nodes should be ignored when using xml::elements
493    #[test]
494    fn vec_element_ignores_text_nodes() {
495        #[derive(facet::Facet, Debug)]
496        #[facet(rename = "any")]
497        struct AnyContainer {
498            #[facet(xml::elements)]
499            elements: Vec<Element>,
500        }
501
502        // Text nodes between elements should be ignored
503        let xml = r#"<any>text before<foo/>middle text<bar/>text after</any>"#;
504        let result: AnyContainer = facet_xml::from_str(xml).unwrap();
505
506        assert_eq!(result.elements.len(), 2);
507        assert_eq!(result.elements[0].tag, "foo");
508        assert_eq!(result.elements[1].tag, "bar");
509    }
510
511    /// Edge case: roundtrip serialization of Vec<Element>
512    ///
513    /// Tests that Element's xml::tag field is used as the element name during
514    /// serialization, producing `<foo>...</foo>` instead of `<element><tag>foo</tag>...</element>`.
515    #[test]
516    fn vec_element_roundtrip() {
517        #[derive(facet::Facet, Debug, PartialEq)]
518        #[facet(rename = "container")]
519        struct Container {
520            #[facet(xml::elements)]
521            elements: Vec<Element>,
522        }
523
524        let original = Container {
525            elements: vec![
526                Element::new("foo").with_attr("a", "1"),
527                Element::new("bar").with_text("hello"),
528            ],
529        };
530
531        let xml = facet_xml::to_string(&original).unwrap();
532
533        // After fix: tag field becomes the element name
534        // <container><foo a="1"/><bar>hello</bar></container>
535        assert!(xml.contains("<foo"), "expected <foo>, got: {}", xml);
536        assert!(xml.contains("<bar"), "expected <bar>, got: {}", xml);
537
538        // Roundtrip should preserve original tag names
539        let roundtripped: Container = facet_xml::from_str(&xml).unwrap();
540        assert_eq!(roundtripped.elements.len(), 2);
541        assert_eq!(roundtripped.elements[0].tag, "foo");
542        assert_eq!(roundtripped.elements[0].get_attr("a"), Some("1"));
543        assert_eq!(roundtripped.elements[1].tag, "bar");
544        assert_eq!(roundtripped.elements[1].text_content(), "hello");
545    }
546
547    /// Edge case: empty container produces empty Vec
548    #[test]
549    fn vec_element_empty_container() {
550        #[derive(facet::Facet, Debug)]
551        #[facet(rename = "empty")]
552        struct EmptyContainer {
553            #[facet(xml::elements)]
554            elements: Vec<Element>,
555        }
556
557        let xml = r#"<empty></empty>"#;
558        let result: EmptyContainer = facet_xml::from_str(xml).unwrap();
559
560        assert!(result.elements.is_empty());
561    }
562
563    #[derive(Debug, Facet)]
564    #[facet(proxy = StringRepr)]
565    struct ConstantName;
566
567    /// A proxy type for Facet that uses the Display/FromStr implementation
568    #[derive(Debug, Facet)]
569    #[repr(transparent)]
570    pub(crate) struct StringRepr(pub String);
571
572    impl Display for ConstantName {
573        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
574            write!(f, "CONSTANT")
575        }
576    }
577
578    impl FromStr for ConstantName {
579        type Err = &'static str;
580        fn from_str(s: &str) -> Result<Self, Self::Err> {
581            if s == "CONSTANT" {
582                Ok(Self)
583            } else {
584                Err("expected `CONSTANT`")
585            }
586        }
587    }
588
589    impl From<ConstantName> for StringRepr {
590        fn from(value: ConstantName) -> Self {
591            Self(value.to_string())
592        }
593    }
594    impl From<&ConstantName> for StringRepr {
595        fn from(value: &ConstantName) -> Self {
596            Self(value.to_string())
597        }
598    }
599    impl TryFrom<StringRepr> for ConstantName {
600        type Error = <ConstantName as core::str::FromStr>::Err;
601        fn try_from(value: StringRepr) -> Result<Self, Self::Error> {
602            value.0.parse()
603        }
604    }
605    impl TryFrom<&StringRepr> for ConstantName {
606        type Error = <ConstantName as core::str::FromStr>::Err;
607        fn try_from(value: &StringRepr) -> Result<Self, Self::Error> {
608            value.0.parse()
609        }
610    }
611
612    #[derive(Debug, Facet)]
613    #[repr(C)]
614    enum Foo {
615        #[facet(rename = "foo")]
616        Value {
617            #[facet(xml::attribute)]
618            #[allow(unused)]
619            name: ConstantName,
620            #[facet(xml::attribute)]
621            #[allow(unused)]
622            exists: String,
623        },
624    }
625
626    #[test]
627    fn transparent_attribute_not_discarded() {
628        let raw_xml = r#"
629<foo name="CONSTANT" exists="i do exist and am not discarded"></foo>"#;
630        let x: Foo = facet_xml::from_str(raw_xml).unwrap();
631        let element = crate::to_element(&x).unwrap();
632        let _ = facet_xml::to_string(&x).unwrap();
633        let _ = facet_xml::to_string(&element).unwrap();
634        assert!(
635            element.attrs.contains_key("exists"),
636            "this attribute is not discarded"
637        );
638        assert_eq!(element.attrs["name"], "CONSTANT", "name is not discarded");
639    }
640}