Skip to main content

netconf_rust/
message.rs

1use std::fmt;
2use std::io::{Cursor, Write};
3use std::ops::Deref;
4use std::sync::atomic::{AtomicU32, Ordering};
5
6use bytes::Bytes;
7use quick_xml::Reader;
8use quick_xml::Writer;
9use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, Event};
10
11const NETCONF_NS: &str = "urn:ietf:params:xml:ns:netconf:base:1.0";
12
13// Global message-id counter shared across all sessions.
14//
15// A NETCONF session is a single SSH connection to a device. Each session
16// has its own stream of RPCs and replies.
17//
18// Each RPC needs a unique message-id so the client can match replies to
19// requests. This is needed for pipelining, where multiple RPCs are
20// sent without waiting for replies — when the replies arrive, the
21// message-id is how we know which reply belongs to which request.
22//
23// IDs only need to be unique within a
24// session, and globally unique is a superset of that.
25//
26// Relaxed ordering: we only need each fetch_add to return a different
27// number. No other memory operations depend on this value, so we don't
28// need the stronger (and more expensive) ordering guarantees.
29static MESSAGE_ID_COUNTER: AtomicU32 = AtomicU32::new(1);
30
31pub fn next_message_id() -> u32 {
32    MESSAGE_ID_COUNTER.fetch_add(1, Ordering::Relaxed)
33}
34
35/// Zero-copy wrapper around a `Bytes` buffer with span offsets pointing to the
36/// `<data>` content within an RPC reply.
37///
38/// `DataPayload` avoids copying the XML content into a `String`. The underlying
39/// `Bytes` is reference-counted (O(1) clone), and `as_str()` returns a view
40/// without allocation. Users who need a `String` can call `into_string()`.
41///
42/// For streaming XML processing, `reader()` returns a `quick_xml::Reader` that
43/// borrows directly from the payload's bytes — events reference the same memory
44/// with no intermediate copies.
45#[derive(Clone)]
46pub struct DataPayload {
47    bytes: Bytes,
48    start: usize,
49    end: usize,
50}
51
52impl DataPayload {
53    /// Create a new `DataPayload` referencing a slice of `bytes`.
54    ///
55    /// # Safety contract
56    /// The caller must ensure `bytes[start..end]` is valid UTF-8.
57    /// This is guaranteed when called from the parser, which validates
58    /// UTF-8 before parsing.
59    pub(crate) fn new(bytes: Bytes, start: usize, end: usize) -> Self {
60        debug_assert!(start <= end);
61        debug_assert!(end <= bytes.len());
62        Self { bytes, start, end }
63    }
64
65    /// Create an empty `DataPayload` (for `<data/>` responses).
66    pub(crate) fn empty() -> Self {
67        Self {
68            bytes: Bytes::new(),
69            start: 0,
70            end: 0,
71        }
72    }
73
74    /// View the data content as `&str` without copying.
75    ///
76    /// This is O(1) — no allocation or UTF-8 validation. The bytes were
77    /// validated as UTF-8 by the reader task before parsing.
78    pub fn as_str(&self) -> &str {
79        // SAFETY: The reader task validates UTF-8 (via std::str::from_utf8)
80        // before the parser constructs a DataPayload. The bytes are immutable
81        // (Bytes is ref-counted), so the UTF-8 invariant is preserved.
82        unsafe { std::str::from_utf8_unchecked(&self.bytes[self.start..self.end]) }
83    }
84
85    /// Convert to an owned `String`.
86    ///
87    /// This copies the data content. Use `as_str()` to avoid the copy
88    /// when you only need a `&str` view.
89    pub fn into_string(self) -> String {
90        self.as_str().to_string()
91    }
92
93    /// View the data content as `&[u8]`.
94    pub fn as_bytes(&self) -> &[u8] {
95        &self.bytes[self.start..self.end]
96    }
97
98    /// Get the underlying `Bytes` slice covering just the data content.
99    pub fn slice(&self) -> Bytes {
100        self.bytes.slice(self.start..self.end)
101    }
102
103    /// Length of the data content in bytes.
104    pub fn len(&self) -> usize {
105        self.end - self.start
106    }
107
108    /// Returns `true` if the data content is empty.
109    pub fn is_empty(&self) -> bool {
110        self.start == self.end
111    }
112
113    /// Create a `quick_xml::Reader` over the data content.
114    ///
115    /// Events borrow directly from the payload's bytes — no intermediate copies.
116    /// This enables StAX-style streaming processing of large responses.
117    pub fn reader(&self) -> Reader<&[u8]> {
118        Reader::from_reader(self.as_bytes())
119    }
120}
121
122impl fmt::Debug for DataPayload {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        let s = self.as_str();
125        if s.len() > 200 {
126            write!(f, "DataPayload({} bytes: {:?}...)", s.len(), &s[..200])
127        } else {
128            write!(f, "DataPayload({:?})", s)
129        }
130    }
131}
132
133impl fmt::Display for DataPayload {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        f.write_str(self.as_str())
136    }
137}
138
139impl Deref for DataPayload {
140    type Target = str;
141
142    fn deref(&self) -> &str {
143        self.as_str()
144    }
145}
146
147impl AsRef<str> for DataPayload {
148    fn as_ref(&self) -> &str {
149        self.as_str()
150    }
151}
152
153impl AsRef<[u8]> for DataPayload {
154    fn as_ref(&self) -> &[u8] {
155        self.as_bytes()
156    }
157}
158
159impl PartialEq<str> for DataPayload {
160    fn eq(&self, other: &str) -> bool {
161        self.as_str() == other
162    }
163}
164
165impl PartialEq<&str> for DataPayload {
166    fn eq(&self, other: &&str) -> bool {
167        self.as_str() == *other
168    }
169}
170
171/// Build an RPC envelope around inner XML content using quick-xml's Writer.
172pub fn build_rpc(inner_xml: &str) -> (u32, String) {
173    let id = next_message_id();
174    let id_str = id.to_string();
175    let mut writer = Writer::new(Cursor::new(Vec::new()));
176
177    writer
178        .write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))
179        .unwrap();
180    writer.get_mut().write_all(b"\n").unwrap();
181
182    let mut rpc = BytesStart::new("rpc");
183    rpc.push_attribute(("message-id", id_str.as_str()));
184    rpc.push_attribute(("xmlns", NETCONF_NS));
185    writer.write_event(Event::Start(rpc)).unwrap();
186
187    // Inner XML is written verbatim — it may be a simple <get/> or complex filter XML
188    writer.get_mut().write_all(b"\n  ").unwrap();
189    writer.get_mut().write_all(inner_xml.as_bytes()).unwrap();
190    writer.get_mut().write_all(b"\n").unwrap();
191
192    writer
193        .write_event(Event::End(BytesEnd::new("rpc")))
194        .unwrap();
195
196    let bytes = writer.into_inner().into_inner();
197    (id, String::from_utf8(bytes).unwrap())
198}
199
200#[derive(Debug)]
201pub struct RpcReply {
202    pub message_id: u32,
203    pub body: RpcReplyBody,
204}
205
206impl RpcReply {
207    /// Extract the data payload from this reply, or propagate an error.
208    ///
209    /// Returns `Ok(DataPayload)` for `Data` replies, `Ok(DataPayload::empty())`
210    /// for `Ok` replies, and `Err` for error replies.
211    pub fn into_data(self) -> crate::Result<DataPayload> {
212        match self.body {
213            RpcReplyBody::Data(payload) => Ok(payload),
214            RpcReplyBody::Ok => Ok(DataPayload::empty()),
215            RpcReplyBody::Error(errors) => Err(crate::Error::Rpc {
216                message_id: self.message_id,
217                error: errors
218                    .first()
219                    .map(|e| e.error_message.clone())
220                    .unwrap_or_default(),
221            }),
222        }
223    }
224}
225
226#[derive(Debug)]
227pub enum RpcReplyBody {
228    Ok,
229    Data(DataPayload),
230    Error(Vec<RpcError>),
231}
232
233#[derive(Debug, Default)]
234pub struct RpcError {
235    pub error_type: String,
236    pub error_tag: String,
237    pub error_severity: String,
238    pub error_message: String,
239}
240
241/// A message received from the NETCONF server.
242#[derive(Debug)]
243pub enum ServerMessage {
244    RpcReply(RpcReply),
245    //Notification(Notification),
246}
247
248/// Classify and parse an incoming server message in a single pass.
249///
250/// Creates one `Reader`, identifies the root element, then continues
251/// parsing with the same reader — no double-parsing.
252///
253/// Takes ownership of `Bytes` from the codec so that `DataPayload` can
254/// reference the buffer without copying.
255pub fn classify_message(bytes: Bytes) -> crate::Result<ServerMessage> {
256    let xml = std::str::from_utf8(&bytes)
257        .map_err(|_| crate::Error::UnexpectedResponse("received non-UTF-8 message".into()))?;
258
259    let mut reader = Reader::from_str(xml);
260    reader.config_mut().trim_text(true);
261
262    loop {
263        match reader.read_event() {
264            Ok(Event::Start(e)) => {
265                let local = e.local_name();
266                match local.as_ref() {
267                    b"rpc-reply" => {
268                        let reply = parse_rpc_reply_body(xml, &bytes, &mut reader, &e)?;
269                        return Ok(ServerMessage::RpcReply(reply));
270                    }
271                    b"notification" => {
272                        unimplemented!("notification not implemented yet")
273                    }
274                    other => {
275                        return Err(crate::Error::UnexpectedResponse(format!(
276                            "unknown root element: <{}>",
277                            String::from_utf8_lossy(other)
278                        )));
279                    }
280                }
281            }
282            Ok(Event::Empty(e)) => {
283                let local = e.local_name();
284                match local.as_ref() {
285                    b"rpc-reply" => {
286                        let message_id = extract_message_id(&e)?;
287                        return Ok(ServerMessage::RpcReply(RpcReply {
288                            message_id,
289                            body: RpcReplyBody::Ok,
290                        }));
291                    }
292                    other => {
293                        return Err(crate::Error::UnexpectedResponse(format!(
294                            "unknown root element: <{}>",
295                            String::from_utf8_lossy(other)
296                        )));
297                    }
298                }
299            }
300            Ok(Event::Decl(_)) | Ok(Event::Comment(_)) | Ok(Event::PI(_)) => continue,
301            Ok(Event::Eof) => {
302                return Err(crate::Error::UnexpectedResponse(
303                    "empty message: no root element".into(),
304                ));
305            }
306            Err(e) => {
307                return Err(crate::Error::UnexpectedResponse(format!(
308                    "XML parse error: {e}"
309                )));
310            }
311            _ => continue,
312        }
313    }
314}
315
316/// Standalone entry point for parsing an `<rpc-reply>` (used by tests).
317pub fn parse_rpc_reply(xml: &str) -> crate::Result<RpcReply> {
318    let bytes = Bytes::from(xml.to_string());
319    let mut reader = Reader::from_str(xml);
320    reader.config_mut().trim_text(true);
321
322    loop {
323        match reader.read_event() {
324            Ok(Event::Start(e)) if e.local_name().as_ref() == b"rpc-reply" => {
325                return parse_rpc_reply_body(xml, &bytes, &mut reader, &e);
326            }
327            Ok(Event::Empty(e)) if e.local_name().as_ref() == b"rpc-reply" => {
328                let message_id = extract_message_id(&e)?;
329                return Ok(RpcReply {
330                    message_id,
331                    body: RpcReplyBody::Ok,
332                });
333            }
334            Ok(Event::Eof) => {
335                return Err(crate::Error::UnexpectedResponse(
336                    "no rpc-reply element found".into(),
337                ));
338            }
339            Err(e) => {
340                return Err(crate::Error::UnexpectedResponse(format!(
341                    "XML parse error: {e}"
342                )));
343            }
344            _ => continue,
345        }
346    }
347}
348
349/// Extract `message-id` from a `<rpc-reply>` element using `try_get_attribute`.
350fn extract_message_id(e: &BytesStart<'_>) -> crate::Result<u32> {
351    let attr = e
352        .try_get_attribute("message-id")
353        .map_err(|err| {
354            crate::Error::UnexpectedResponse(format!("invalid rpc-reply attributes: {err}"))
355        })?
356        .ok_or_else(|| {
357            crate::Error::UnexpectedResponse("missing message-id in rpc-reply".into())
358        })?;
359
360    let val = attr.unescape_value().map_err(|err| {
361        crate::Error::UnexpectedResponse(format!("invalid message-id attr: {err}"))
362    })?;
363
364    val.parse::<u32>()
365        .map_err(|e| crate::Error::UnexpectedResponse(format!("invalid message-id '{val}': {e}")))
366}
367
368/// Parse the body of an `<rpc-reply>` after the root Start event has been consumed.
369///
370/// Uses span-based slicing for `<data>` content (zero-copy from the original XML)
371/// and inline parsing for `<rpc-error>` (no re-parse of the document).
372///
373/// The `raw` parameter is the ref-counted `Bytes` buffer from the codec, used
374/// to construct `DataPayload` without copying.
375fn parse_rpc_reply_body(
376    xml: &str,
377    raw: &Bytes,
378    reader: &mut Reader<&[u8]>,
379    root: &BytesStart<'_>,
380) -> crate::Result<RpcReply> {
381    let message_id = extract_message_id(root)?;
382    let mut body: Option<RpcReplyBody> = None;
383
384    loop {
385        match reader.read_event() {
386            Ok(Event::Start(e)) => {
387                let local = e.local_name();
388                match local.as_ref() {
389                    b"data" => {
390                        // Span-based: read_to_end returns byte offsets into the original XML.
391                        // Slice the original string directly — zero-copy, exact preservation.
392                        let span = reader.read_to_end(e.name()).map_err(|e| {
393                            crate::Error::UnexpectedResponse(format!(
394                                "XML parse error in <data>: {e}"
395                            ))
396                        })?;
397                        let inner = xml[span.start as usize..span.end as usize].trim();
398                        let trimmed_start = inner.as_ptr() as usize - xml.as_ptr() as usize;
399                        let trimmed_end = trimmed_start + inner.len();
400                        body = Some(RpcReplyBody::Data(DataPayload::new(
401                            raw.clone(),
402                            trimmed_start,
403                            trimmed_end,
404                        )));
405                    }
406                    b"rpc-error" => {
407                        // Inline error parsing: continue with the current reader
408                        // instead of re-parsing the entire document.
409                        let first_error = parse_single_rpc_error(reader)?;
410                        let mut errors = vec![first_error];
411
412                        // Check for additional <rpc-error> elements
413                        loop {
414                            match reader.read_event() {
415                                Ok(Event::Start(e2))
416                                    if e2.local_name().as_ref() == b"rpc-error" =>
417                                {
418                                    errors.push(parse_single_rpc_error(reader)?);
419                                }
420                                Ok(Event::End(_)) | Ok(Event::Eof) => break,
421                                Err(e) => {
422                                    return Err(crate::Error::UnexpectedResponse(format!(
423                                        "XML parse error: {e}"
424                                    )));
425                                }
426                                _ => {}
427                            }
428                        }
429
430                        body = Some(RpcReplyBody::Error(errors));
431                        break;
432                    }
433                    _ => {}
434                }
435            }
436            Ok(Event::Empty(e)) => match e.local_name().as_ref() {
437                b"ok" => body = Some(RpcReplyBody::Ok),
438                b"data" => body = Some(RpcReplyBody::Data(DataPayload::empty())),
439                _ => {}
440            },
441            Ok(Event::Eof) => break,
442            Err(e) => {
443                return Err(crate::Error::UnexpectedResponse(format!(
444                    "XML parse error: {e}"
445                )));
446            }
447            _ => {}
448        }
449    }
450
451    let body = body.unwrap_or(RpcReplyBody::Ok);
452    Ok(RpcReply { message_id, body })
453}
454
455/// Fields within an `<rpc-error>` element.
456#[derive(Clone, Copy)]
457enum ErrorField {
458    Type,
459    Tag,
460    Severity,
461    Message,
462}
463
464/// Parse a single `<rpc-error>` block from the current reader position.
465///
466/// Called after the `<rpc-error>` Start event has been consumed.
467/// Reads until the matching `</rpc-error>` End event.
468fn parse_single_rpc_error(reader: &mut Reader<&[u8]>) -> crate::Result<RpcError> {
469    let mut error = RpcError::default();
470    let mut current_field: Option<ErrorField> = None;
471
472    loop {
473        match reader.read_event() {
474            Ok(Event::Start(e)) => {
475                current_field = match e.local_name().as_ref() {
476                    b"error-type" => Some(ErrorField::Type),
477                    b"error-tag" => Some(ErrorField::Tag),
478                    b"error-severity" => Some(ErrorField::Severity),
479                    b"error-message" => Some(ErrorField::Message),
480                    _ => None,
481                };
482            }
483            Ok(Event::Text(e)) => {
484                if let Some(field) = current_field {
485                    let text = e.xml_content().unwrap_or_default().to_string();
486                    match field {
487                        ErrorField::Type => error.error_type = text,
488                        ErrorField::Tag => error.error_tag = text,
489                        ErrorField::Severity => error.error_severity = text,
490                        ErrorField::Message => error.error_message = text,
491                    }
492                }
493            }
494            Ok(Event::End(e)) => {
495                if e.local_name().as_ref() == b"rpc-error" {
496                    break;
497                }
498                current_field = None;
499            }
500            Ok(Event::Eof) => break,
501            Err(e) => {
502                return Err(crate::Error::UnexpectedResponse(format!(
503                    "XML parse error: {e}"
504                )));
505            }
506            _ => {}
507        }
508    }
509
510    Ok(error)
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    #[test]
518    fn test_build_rpc() {
519        let (id, xml) = build_rpc("<get/>");
520        assert!(id > 0);
521        assert!(xml.contains(&format!("message-id=\"{id}\"")));
522        assert!(xml.contains("<get/>"));
523        assert!(xml.contains("<rpc"));
524        assert!(xml.contains("</rpc>"));
525    }
526
527    #[test]
528    fn test_parse_ok_reply() {
529        let xml = r#"<rpc-reply message-id="1" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
530  <ok/>
531</rpc-reply>"#;
532        let reply = parse_rpc_reply(xml).unwrap();
533        assert_eq!(reply.message_id, 1);
534        assert!(matches!(reply.body, RpcReplyBody::Ok));
535    }
536
537    #[test]
538    fn test_parse_data_reply() {
539        let xml = r#"<rpc-reply message-id="2" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
540  <data>
541    <interfaces xmlns="urn:example:interfaces">
542      <interface>
543        <name>eth0</name>
544      </interface>
545    </interfaces>
546  </data>
547</rpc-reply>"#;
548        let reply = parse_rpc_reply(xml).unwrap();
549        assert_eq!(reply.message_id, 2);
550        match &reply.body {
551            RpcReplyBody::Data(data) => {
552                assert!(data.contains("<interfaces"));
553                assert!(data.contains("eth0"));
554            }
555            _ => panic!("expected Data reply"),
556        }
557    }
558
559    #[test]
560    fn test_parse_error_reply() {
561        let xml = r#"<rpc-reply message-id="3" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
562  <rpc-error>
563    <error-type>application</error-type>
564    <error-tag>invalid-value</error-tag>
565    <error-severity>error</error-severity>
566    <error-message>Invalid input</error-message>
567  </rpc-error>
568</rpc-reply>"#;
569        let reply = parse_rpc_reply(xml).unwrap();
570        assert_eq!(reply.message_id, 3);
571        match &reply.body {
572            RpcReplyBody::Error(errors) => {
573                assert_eq!(errors.len(), 1);
574                assert_eq!(errors[0].error_type, "application");
575                assert_eq!(errors[0].error_tag, "invalid-value");
576                assert_eq!(errors[0].error_severity, "error");
577                assert_eq!(errors[0].error_message, "Invalid input");
578            }
579            _ => panic!("expected Error reply"),
580        }
581    }
582
583    #[test]
584    fn test_parse_multiple_errors() {
585        let xml = r#"<rpc-reply message-id="10" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
586  <rpc-error>
587    <error-type>application</error-type>
588    <error-tag>invalid-value</error-tag>
589    <error-severity>error</error-severity>
590    <error-message>First error</error-message>
591  </rpc-error>
592  <rpc-error>
593    <error-type>protocol</error-type>
594    <error-tag>bad-element</error-tag>
595    <error-severity>error</error-severity>
596    <error-message>Second error</error-message>
597  </rpc-error>
598</rpc-reply>"#;
599        let reply = parse_rpc_reply(xml).unwrap();
600        assert_eq!(reply.message_id, 10);
601        match &reply.body {
602            RpcReplyBody::Error(errors) => {
603                assert_eq!(errors.len(), 2);
604                assert_eq!(errors[0].error_message, "First error");
605                assert_eq!(errors[1].error_message, "Second error");
606                assert_eq!(errors[1].error_type, "protocol");
607            }
608            _ => panic!("expected Error reply"),
609        }
610    }
611
612    #[test]
613    fn test_parse_empty_data_reply() {
614        let xml = r#"<rpc-reply message-id="4" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
615  <data/>
616</rpc-reply>"#;
617        let reply = parse_rpc_reply(xml).unwrap();
618        assert_eq!(reply.message_id, 4);
619        assert!(matches!(reply.body, RpcReplyBody::Data(ref s) if s.is_empty()));
620    }
621
622    #[test]
623    fn test_message_ids_increment() {
624        let (id1, _) = build_rpc("<get/>");
625        let (id2, _) = build_rpc("<get/>");
626        assert_eq!(id2, id1 + 1);
627    }
628
629    #[test]
630    fn test_data_preserves_inner_xml_exactly() {
631        let xml = r#"<rpc-reply message-id="5" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
632  <data>
633    <!-- comment preserved -->
634    <config xmlns="urn:example">
635      <value attr="x &amp; y">text</value>
636    </config>
637  </data>
638</rpc-reply>"#;
639        let reply = parse_rpc_reply(xml).unwrap();
640        match &reply.body {
641            RpcReplyBody::Data(data) => {
642                // Span-based extraction preserves comments, entities, and attributes exactly
643                assert!(data.contains("<!-- comment preserved -->"));
644                assert!(data.contains("x &amp; y"));
645                assert!(data.contains("<config"));
646            }
647            _ => panic!("expected Data reply"),
648        }
649    }
650
651    // ── classify_message tests ──────────────────────────────────────────
652
653    #[test]
654    fn classify_rpc_reply() {
655        let xml = r#"<rpc-reply message-id="1" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"><ok/></rpc-reply>"#;
656        let msg = classify_message(Bytes::from(xml)).unwrap();
657        assert!(matches!(msg, ServerMessage::RpcReply(_)));
658    }
659
660    #[test]
661    fn classify_with_xml_declaration() {
662        let xml = r#"<?xml version="1.0" encoding="UTF-8"?><rpc-reply message-id="5" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"><ok/></rpc-reply>"#;
663        let msg = classify_message(Bytes::from(xml)).unwrap();
664        assert!(matches!(msg, ServerMessage::RpcReply(_)));
665    }
666
667    #[test]
668    fn classify_unknown_root() {
669        let xml = r#"<unknown-element/>"#;
670        let result = classify_message(Bytes::from(xml));
671        assert!(result.is_err());
672    }
673
674    #[test]
675    fn classify_empty_message() {
676        let result = classify_message(Bytes::from(""));
677        assert!(result.is_err());
678    }
679
680    // ── DataPayload tests ──────────────────────────────────────────────
681
682    #[test]
683    fn data_payload_as_str() {
684        let xml = r#"<rpc-reply message-id="20" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
685  <data>
686    <config><hostname>router1</hostname></config>
687  </data>
688</rpc-reply>"#;
689        let reply = parse_rpc_reply(xml).unwrap();
690        match &reply.body {
691            RpcReplyBody::Data(payload) => {
692                let s = payload.as_str();
693                assert!(s.contains("<config>"));
694                assert!(s.contains("router1"));
695                // Deref<Target=str> works
696                assert!(payload.contains("router1"));
697            }
698            _ => panic!("expected Data reply"),
699        }
700    }
701
702    #[test]
703    fn data_payload_into_string() {
704        let xml = r#"<rpc-reply message-id="21" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
705  <data><value>hello</value></data>
706</rpc-reply>"#;
707        let reply = parse_rpc_reply(xml).unwrap();
708        match reply.body {
709            RpcReplyBody::Data(payload) => {
710                let s = payload.into_string();
711                assert!(s.contains("<value>hello</value>"));
712            }
713            _ => panic!("expected Data reply"),
714        }
715    }
716
717    #[test]
718    fn data_payload_reader() {
719        let xml = r#"<rpc-reply message-id="22" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
720  <data><item>one</item><item>two</item></data>
721</rpc-reply>"#;
722        let reply = parse_rpc_reply(xml).unwrap();
723        match &reply.body {
724            RpcReplyBody::Data(payload) => {
725                let mut reader = payload.reader();
726                let mut buf = Vec::new();
727                let mut items = Vec::new();
728                loop {
729                    match reader.read_event_into(&mut buf) {
730                        Ok(Event::Start(e)) if e.local_name().as_ref() == b"item" => {}
731                        Ok(Event::Text(e)) => {
732                            items.push(e.xml_content().unwrap().to_string());
733                        }
734                        Ok(Event::Eof) => break,
735                        _ => {}
736                    }
737                    buf.clear();
738                }
739                assert_eq!(items, vec!["one", "two"]);
740            }
741            _ => panic!("expected Data reply"),
742        }
743    }
744
745    #[test]
746    fn data_payload_empty() {
747        let payload = DataPayload::empty();
748        assert!(payload.is_empty());
749        assert_eq!(payload.len(), 0);
750        assert_eq!(payload.as_str(), "");
751        assert_eq!(payload.into_string(), "");
752    }
753
754    #[test]
755    fn data_payload_len_and_is_empty() {
756        let xml = r#"<rpc-reply message-id="23" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
757  <data>abc</data>
758</rpc-reply>"#;
759        let reply = parse_rpc_reply(xml).unwrap();
760        match &reply.body {
761            RpcReplyBody::Data(payload) => {
762                assert_eq!(payload.len(), 3);
763                assert!(!payload.is_empty());
764            }
765            _ => panic!("expected Data reply"),
766        }
767    }
768
769    #[test]
770    fn data_payload_large_preserves_content() {
771        // Generate a large data payload to verify no truncation
772        let inner = "<item>x</item>".repeat(1000);
773        let xml = format!(
774            r#"<rpc-reply message-id="24" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
775  <data>{inner}</data>
776</rpc-reply>"#
777        );
778        let reply = parse_rpc_reply(&xml).unwrap();
779        match &reply.body {
780            RpcReplyBody::Data(payload) => {
781                assert_eq!(payload.as_str(), inner);
782                assert_eq!(payload.len(), inner.len());
783            }
784            _ => panic!("expected Data reply"),
785        }
786    }
787
788    #[test]
789    fn data_payload_partial_eq() {
790        let xml = r#"<rpc-reply message-id="25" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
791  <data>hello</data>
792</rpc-reply>"#;
793        let reply = parse_rpc_reply(xml).unwrap();
794        match &reply.body {
795            RpcReplyBody::Data(payload) => {
796                assert!(*payload == *"hello");
797                assert!(*payload != *"world");
798            }
799            _ => panic!("expected Data reply"),
800        }
801    }
802
803    #[test]
804    fn rpc_reply_into_data() {
805        let xml = r#"<rpc-reply message-id="26" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
806  <data><config/></data>
807</rpc-reply>"#;
808        let reply = parse_rpc_reply(xml).unwrap();
809        let payload = reply.into_data().unwrap();
810        assert!(payload.contains("<config/>"));
811    }
812
813    #[test]
814    fn rpc_reply_into_data_ok() {
815        let xml = r#"<rpc-reply message-id="27" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
816  <ok/>
817</rpc-reply>"#;
818        let reply = parse_rpc_reply(xml).unwrap();
819        let payload = reply.into_data().unwrap();
820        assert!(payload.is_empty());
821    }
822
823    #[test]
824    fn rpc_reply_into_data_error() {
825        let xml = r#"<rpc-reply message-id="28" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
826  <rpc-error>
827    <error-type>application</error-type>
828    <error-tag>invalid-value</error-tag>
829    <error-severity>error</error-severity>
830    <error-message>bad</error-message>
831  </rpc-error>
832</rpc-reply>"#;
833        let reply = parse_rpc_reply(xml).unwrap();
834        assert!(reply.into_data().is_err());
835    }
836}