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 super::*;
265
266    #[test]
267    fn element_builder_api() {
268        let elem = Element::new("root")
269            .with_attr("id", "123")
270            .with_child(Element::new("child").with_text("hello world"));
271
272        assert_eq!(elem.tag, "root");
273        assert_eq!(elem.get_attr("id"), Some("123"));
274        assert_eq!(elem.children.len(), 1);
275
276        let child = elem.child_elements().next().unwrap();
277        assert_eq!(child.tag, "child");
278        assert_eq!(child.text_content(), "hello world");
279    }
280
281    #[test]
282    fn parse_simple_xml() {
283        let xml = r#"<root><child>hello</child></root>"#;
284        let elem: Element = facet_xml::from_str(xml).unwrap();
285
286        assert_eq!(elem.tag, "root");
287        assert_eq!(elem.children.len(), 1);
288
289        let child = elem.child_elements().next().unwrap();
290        assert_eq!(child.tag, "child");
291        assert_eq!(child.text_content(), "hello");
292    }
293
294    #[test]
295    fn parse_with_attributes() {
296        let xml = r#"<root id="123" class="test"><child name="foo">bar</child></root>"#;
297        let elem: Element = facet_xml::from_str(xml).unwrap();
298
299        assert_eq!(elem.tag, "root");
300        assert_eq!(elem.get_attr("id"), Some("123"));
301        assert_eq!(elem.get_attr("class"), Some("test"));
302
303        let child = elem.child_elements().next().unwrap();
304        assert_eq!(child.get_attr("name"), Some("foo"));
305        assert_eq!(child.text_content(), "bar");
306    }
307
308    #[test]
309    fn parse_mixed_content() {
310        let xml = r#"<p>Hello <b>world</b>!</p>"#;
311        let elem: Element = facet_xml::from_str(xml).unwrap();
312
313        assert_eq!(elem.tag, "p");
314        assert_eq!(elem.children.len(), 3);
315        // Note: trailing whitespace is trimmed by XML parser
316        assert_eq!(elem.children[0].as_text(), Some("Hello"));
317        assert_eq!(elem.children[1].as_element().unwrap().tag, "b");
318        assert_eq!(elem.children[2].as_text(), Some("!"));
319        assert_eq!(elem.text_content(), "Helloworld!");
320    }
321
322    #[test]
323    fn from_element_to_struct() {
324        #[derive(facet::Facet, Debug, PartialEq)]
325        struct Person {
326            name: String,
327            age: u32,
328        }
329
330        let elem = Element::new("person")
331            .with_child(Element::new("name").with_text("Alice"))
332            .with_child(Element::new("age").with_text("30"));
333
334        let person: Person = from_element(&elem).unwrap();
335        assert_eq!(person.name, "Alice");
336        assert_eq!(person.age, 30);
337    }
338
339    #[test]
340    fn from_element_with_attrs() {
341        #[derive(facet::Facet, Debug, PartialEq)]
342        struct Item {
343            #[facet(xml::attribute)]
344            id: String,
345            value: String,
346        }
347
348        let elem = Element::new("item")
349            .with_attr("id", "123")
350            .with_child(Element::new("value").with_text("hello"));
351
352        let item: Item = from_element(&elem).unwrap();
353        assert_eq!(item.id, "123");
354        assert_eq!(item.value, "hello");
355    }
356
357    #[test]
358    fn to_element_simple() {
359        #[derive(facet::Facet, Debug, PartialEq)]
360        struct Person {
361            name: String,
362            age: u32,
363        }
364
365        let person = Person {
366            name: "Alice".to_string(),
367            age: 30,
368        };
369
370        let elem = to_element(&person).unwrap();
371        assert_eq!(elem.tag, "person");
372        assert_eq!(elem.children.len(), 2);
373
374        let name_child = elem.child_elements().find(|e| e.tag == "name").unwrap();
375        assert_eq!(name_child.text_content(), "Alice");
376
377        let age_child = elem.child_elements().find(|e| e.tag == "age").unwrap();
378        assert_eq!(age_child.text_content(), "30");
379    }
380
381    #[test]
382    fn to_element_with_attrs() {
383        #[derive(facet::Facet, Debug, PartialEq)]
384        struct Item {
385            #[facet(xml::attribute)]
386            id: String,
387            value: String,
388        }
389
390        let item = Item {
391            id: "123".to_string(),
392            value: "hello".to_string(),
393        };
394
395        let elem = to_element(&item).unwrap();
396        assert_eq!(elem.tag, "item");
397        assert_eq!(elem.get_attr("id"), Some("123"));
398
399        let value_child = elem.child_elements().find(|e| e.tag == "value").unwrap();
400        assert_eq!(value_child.text_content(), "hello");
401    }
402
403    #[test]
404    fn roundtrip_simple() {
405        #[derive(facet::Facet, Debug, PartialEq)]
406        struct Person {
407            name: String,
408            age: u32,
409        }
410
411        let original = Person {
412            name: "Bob".to_string(),
413            age: 42,
414        };
415
416        let elem = to_element(&original).unwrap();
417        let roundtripped: Person = from_element(&elem).unwrap();
418
419        assert_eq!(original, roundtripped);
420    }
421
422    #[test]
423    fn roundtrip_with_attrs() {
424        #[derive(facet::Facet, Debug, PartialEq)]
425        struct Item {
426            #[facet(xml::attribute)]
427            id: String,
428            #[facet(xml::attribute)]
429            version: u32,
430            value: String,
431        }
432
433        let original = Item {
434            id: "test-123".to_string(),
435            version: 5,
436            value: "content".to_string(),
437        };
438
439        let elem = to_element(&original).unwrap();
440        let roundtripped: Item = from_element(&elem).unwrap();
441
442        assert_eq!(original, roundtripped);
443    }
444}