Skip to main content

rust_ethernet_ip/
tag_path.rs

1// tag_path.rs - Advanced Tag Path Parsing for Allen-Bradley PLCs
2// =========================================================================
3//
4// This module provides comprehensive tag path parsing and generation for
5// Allen-Bradley CompactLogix and ControlLogix PLCs, supporting:
6//
7// - Program-scoped tags: "Program:MainProgram.Tag1"
8// - Array elements: "MyArray[5]", "MyArray[1,2,3]"
9// - Bit access: "MyDINT.15" (access individual bits)
10// - UDT members: "MyUDT.Member1.SubMember"
11// - String operations: "MyString.LEN", "MyString.DATA[5]"
12//
13// =========================================================================
14
15use crate::error::{EtherNetIpError, Result};
16use std::fmt;
17
18/// Represents different types of tag addressing supported by Allen-Bradley PLCs
19#[derive(Debug, Clone, PartialEq)]
20pub enum TagPath {
21    /// Simple controller-scoped tag: `MyTag`
22    Controller { tag_name: String },
23
24    /// Program-scoped tag: `Program:MainProgram.MyTag`
25    Program {
26        program_name: String,
27        tag_name: String,
28    },
29
30    /// Array element access: `MyArray[5]` or `MyArray[1,2,3]`
31    Array {
32        base_path: Box<TagPath>,
33        indices: Vec<u32>,
34    },
35
36    /// Bit access within a tag: "MyDINT.15"
37    Bit {
38        base_path: Box<TagPath>,
39        bit_index: u8,
40    },
41
42    /// UDT member access: "MyUDT.Member1"
43    Member {
44        base_path: Box<TagPath>,
45        member_name: String,
46    },
47
48    /// String length access: "MyString.LEN"
49    StringLength { base_path: Box<TagPath> },
50
51    /// String data access: "MyString.DATA[5]"
52    StringData { base_path: Box<TagPath>, index: u32 },
53}
54
55impl TagPath {
56    /// Parses a tag path string into a structured `TagPath`
57    ///
58    /// # Examples
59    ///
60    /// ```rust
61    /// use rust_ethernet_ip::TagPath;
62    ///
63    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
64    /// // Simple controller tag
65    /// let path = TagPath::parse("MyTag")?;
66    ///
67    /// // Program-scoped tag
68    /// let path = TagPath::parse("Program:MainProgram.MyTag")?;
69    ///
70    /// // Array element
71    /// let path = TagPath::parse("MyArray[5]")?;
72    ///
73    /// // Multi-dimensional array
74    /// let path = TagPath::parse("Matrix[1,2,3]")?;
75    ///
76    /// // Bit access
77    /// let path = TagPath::parse("StatusWord.15")?;
78    ///
79    /// // UDT member
80    /// let path = TagPath::parse("MotorData.Speed")?;
81    ///
82    /// // Complex nested path
83    /// let path = TagPath::parse("Program:Safety.Devices[2].Status.15")?;
84    /// # Ok(())
85    /// # }
86    /// ```
87    pub fn parse(path_str: &str) -> Result<Self> {
88        let parser = TagPathParser::new(path_str);
89        parser.parse()
90    }
91
92    /// Converts the `TagPath` back to a string representation
93    pub fn as_string(&self) -> String {
94        match self {
95            TagPath::Controller { tag_name } => tag_name.clone(),
96            TagPath::Program {
97                program_name,
98                tag_name,
99            } => {
100                format!("Program:{program_name}.{tag_name}")
101            }
102            TagPath::Array { base_path, indices } => {
103                let base = base_path.as_string();
104                let indices_str = indices
105                    .iter()
106                    .map(u32::to_string)
107                    .collect::<Vec<_>>()
108                    .join(",");
109                format!("{base}[{indices_str}]")
110            }
111            TagPath::Bit {
112                base_path,
113                bit_index,
114            } => {
115                format!("{base_path}.{bit_index}")
116            }
117            TagPath::Member {
118                base_path,
119                member_name,
120            } => {
121                format!("{base_path}.{member_name}")
122            }
123            TagPath::StringLength { base_path } => {
124                format!("{base_path}.LEN")
125            }
126            TagPath::StringData { base_path, index } => {
127                format!("{base_path}.DATA[{index}]")
128            }
129        }
130    }
131
132    /// Generates the CIP path bytes for this tag path
133    ///
134    /// This converts the structured tag path into the binary format
135    /// required by the CIP protocol for EtherNet/IP communication.
136    pub fn to_cip_path(&self) -> Result<Vec<u8>> {
137        let mut path = Vec::new();
138        self.build_cip_path(&mut path)?;
139
140        // Pad to even length if necessary
141        if path.len() % 2 != 0 {
142            path.push(0x00);
143        }
144
145        Ok(path)
146    }
147
148    /// Recursively builds the CIP path bytes
149    fn build_cip_path(&self, path: &mut Vec<u8>) -> Result<()> {
150        match self {
151            TagPath::Controller { tag_name } => {
152                // ANSI Extended Symbol Segment
153                path.push(0x91);
154                path.push(tag_name.len() as u8);
155                path.extend_from_slice(tag_name.as_bytes());
156            }
157
158            TagPath::Program {
159                program_name,
160                tag_name,
161            } => {
162                // Program scope requires special handling
163                // First add program name segment
164                path.push(0x91);
165                let program_path = format!("Program:{program_name}");
166                path.push(program_path.len() as u8);
167                path.extend_from_slice(program_path.as_bytes());
168
169                // Pad to even length if necessary after program segment
170                if path.len() % 2 != 0 {
171                    path.push(0x00);
172                }
173
174                // Then add tag name segment
175                path.push(0x91);
176                path.push(tag_name.len() as u8);
177                path.extend_from_slice(tag_name.as_bytes());
178            }
179
180            TagPath::Array { base_path, indices } => {
181                // Build base path first
182                base_path.build_cip_path(path)?;
183
184                // Pad to even length if necessary before adding array segments
185                if path.len() % 2 != 0 {
186                    path.push(0x00);
187                }
188
189                // Add array indices using proper Element ID segment format
190                // Reference: 1756-PM020, Pages 603-611, 870-890
191                // Element ID segments use different sizes based on index value:
192                // - 0-255: 8-bit Element ID (0x28 + 1 byte value)
193                // - 256-65535: 16-bit Element ID (0x29 0x00 + 2 bytes low, high)
194                // - 65536+: 32-bit Element ID (0x2A 0x00 + 4 bytes lowest to highest)
195                for &index in indices {
196                    if index <= 255 {
197                        // 8-bit Element ID: 0x28 + index (2 bytes total)
198                        path.push(0x28);
199                        path.push(index as u8);
200                    } else if index <= 65535 {
201                        // 16-bit Element ID: 0x29, 0x00, low_byte, high_byte (4 bytes total)
202                        path.push(0x29);
203                        path.push(0x00); // Padding byte
204                        path.extend_from_slice(&(index as u16).to_le_bytes());
205                    } else {
206                        // 32-bit Element ID: 0x2A, 0x00, byte0, byte1, byte2, byte3 (6 bytes total)
207                        path.push(0x2A);
208                        path.push(0x00); // Padding byte
209                        path.extend_from_slice(&index.to_le_bytes());
210                    }
211                }
212            }
213
214            TagPath::Bit {
215                base_path,
216                bit_index,
217            } => {
218                // Build base path first
219                base_path.build_cip_path(path)?;
220
221                // Pad to even length if necessary before adding bit segment
222                if path.len() % 2 != 0 {
223                    path.push(0x00);
224                }
225
226                // Add bit segment
227                path.push(0x29); // Bit segment
228                path.push(*bit_index);
229            }
230
231            TagPath::Member {
232                base_path,
233                member_name,
234            } => {
235                // Build base path first
236                base_path.build_cip_path(path)?;
237
238                // Pad to even length if necessary before adding member segment
239                if path.len() % 2 != 0 {
240                    path.push(0x00);
241                }
242
243                // Add member segment
244                path.push(0x91);
245                path.push(member_name.len() as u8);
246                path.extend_from_slice(member_name.as_bytes());
247            }
248
249            TagPath::StringLength { base_path } => {
250                // Build base path first
251                base_path.build_cip_path(path)?;
252
253                // Pad to even length if necessary before adding member segment
254                if path.len() % 2 != 0 {
255                    path.push(0x00);
256                }
257
258                // Add LEN member
259                path.push(0x91);
260                path.push(3); // "LEN".len()
261                path.extend_from_slice(b"LEN");
262            }
263
264            TagPath::StringData { base_path, index } => {
265                // Build base path first
266                base_path.build_cip_path(path)?;
267
268                // Pad to even length if necessary before adding member segment
269                if path.len() % 2 != 0 {
270                    path.push(0x00);
271                }
272
273                // Add DATA member
274                path.push(0x91);
275                path.push(4); // "DATA".len()
276                path.extend_from_slice(b"DATA");
277
278                // Pad to even length if necessary before adding array segment
279                if path.len() % 2 != 0 {
280                    path.push(0x00);
281                }
282
283                // Add array index
284                path.push(0x28); // Element segment
285                path.push(0x04); // Size: 4 bytes for 32-bit index (DINT)
286                let index_u32 = *index;
287                path.extend_from_slice(&index_u32.to_le_bytes());
288            }
289        }
290
291        Ok(())
292    }
293
294    /// Returns the base tag name without any path qualifiers
295    pub fn base_tag_name(&self) -> String {
296        match self {
297            TagPath::Controller { tag_name } => tag_name.clone(),
298            TagPath::Program { tag_name, .. } => tag_name.clone(),
299            TagPath::Array { base_path, .. } => base_path.base_tag_name(),
300            TagPath::Bit { base_path, .. } => base_path.base_tag_name(),
301            TagPath::Member { base_path, .. } => base_path.base_tag_name(),
302            TagPath::StringLength { base_path } => base_path.base_tag_name(),
303            TagPath::StringData { base_path, .. } => base_path.base_tag_name(),
304        }
305    }
306
307    /// Returns true if this is a program-scoped tag
308    pub fn is_program_scoped(&self) -> bool {
309        match self {
310            TagPath::Program { .. } => true,
311            TagPath::Array { base_path, .. } => base_path.is_program_scoped(),
312            TagPath::Bit { base_path, .. } => base_path.is_program_scoped(),
313            TagPath::Member { base_path, .. } => base_path.is_program_scoped(),
314            TagPath::StringLength { base_path } => base_path.is_program_scoped(),
315            TagPath::StringData { base_path, .. } => base_path.is_program_scoped(),
316            TagPath::Controller { .. } => false,
317        }
318    }
319
320    /// Returns the program name if this is a program-scoped tag
321    pub fn program_name(&self) -> Option<String> {
322        match self {
323            TagPath::Program { program_name, .. } => Some(program_name.clone()),
324            TagPath::Array { base_path, .. } => base_path.program_name(),
325            TagPath::Bit { base_path, .. } => base_path.program_name(),
326            TagPath::Member { base_path, .. } => base_path.program_name(),
327            TagPath::StringLength { base_path } => base_path.program_name(),
328            TagPath::StringData { base_path, .. } => base_path.program_name(),
329            TagPath::Controller { .. } => None,
330        }
331    }
332}
333
334impl fmt::Display for TagPath {
335    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336        write!(f, "{}", self.as_string())
337    }
338}
339
340/// Internal parser for tag path strings
341struct TagPathParser<'a> {
342    input: &'a str,
343    position: usize,
344}
345
346impl<'a> TagPathParser<'a> {
347    fn new(input: &'a str) -> Self {
348        Self { input, position: 0 }
349    }
350
351    fn parse(mut self) -> Result<TagPath> {
352        self.parse_path()
353    }
354
355    fn parse_path(&mut self) -> Result<TagPath> {
356        // Check for program scope
357        if self.input.starts_with("Program:") {
358            self.parse_program_scoped()
359        } else {
360            self.parse_controller_scoped()
361        }
362    }
363
364    fn parse_program_scoped(&mut self) -> Result<TagPath> {
365        // Skip "Program:"
366        self.position = 8;
367
368        // Parse program name (until first dot)
369        let program_name = self.parse_identifier()?;
370
371        // Expect dot
372        if !self.consume_char('.') {
373            return Err(EtherNetIpError::Protocol(
374                "Expected '.' after program name".to_string(),
375            ));
376        }
377
378        // Parse tag name
379        let tag_name = self.parse_identifier()?;
380
381        let mut path = TagPath::Program {
382            program_name,
383            tag_name,
384        };
385
386        // Parse any additional qualifiers (arrays, members, bits)
387        while self.position < self.input.len() {
388            path = self.parse_qualifier(path)?;
389        }
390
391        Ok(path)
392    }
393
394    fn parse_controller_scoped(&mut self) -> Result<TagPath> {
395        let tag_name = self.parse_identifier()?;
396        let mut path = TagPath::Controller { tag_name };
397
398        // Parse any additional qualifiers
399        while self.position < self.input.len() {
400            path = self.parse_qualifier(path)?;
401        }
402
403        Ok(path)
404    }
405
406    fn parse_qualifier(&mut self, base_path: TagPath) -> Result<TagPath> {
407        match self.peek_char() {
408            Some('[') => self.parse_array_access(base_path),
409            Some('.') => self.parse_member_or_bit_access(base_path),
410            _ => Err(EtherNetIpError::Protocol(format!(
411                "Unexpected character at position {}",
412                self.position
413            ))),
414        }
415    }
416
417    fn parse_array_access(&mut self, base_path: TagPath) -> Result<TagPath> {
418        // Consume '['
419        self.consume_char('[');
420
421        let mut indices = Vec::new();
422
423        // Parse first index
424        indices.push(self.parse_number()?);
425
426        // Parse additional indices separated by commas
427        while self.peek_char() == Some(',') {
428            self.consume_char(',');
429            indices.push(self.parse_number()?);
430        }
431
432        // Expect ']'
433        if !self.consume_char(']') {
434            return Err(EtherNetIpError::Protocol(
435                "Expected ']' after array indices".to_string(),
436            ));
437        }
438
439        Ok(TagPath::Array {
440            base_path: Box::new(base_path),
441            indices,
442        })
443    }
444
445    fn parse_member_or_bit_access(&mut self, base_path: TagPath) -> Result<TagPath> {
446        // Consume '.'
447        self.consume_char('.');
448
449        // Check for special string operations
450        if self.input[self.position..].starts_with("LEN") {
451            self.position += 3;
452            return Ok(TagPath::StringLength {
453                base_path: Box::new(base_path),
454            });
455        }
456
457        if self.input[self.position..].starts_with("DATA[") {
458            self.position += 5; // Skip "DATA["
459            let index = self.parse_number()?;
460            if !self.consume_char(']') {
461                return Err(EtherNetIpError::Protocol(
462                    "Expected ']' after DATA index".to_string(),
463                ));
464            }
465            return Ok(TagPath::StringData {
466                base_path: Box::new(base_path),
467                index,
468            });
469        }
470
471        // Parse identifier (could be member name or bit index)
472        let identifier = self.parse_identifier()?;
473
474        // Check if it's a numeric bit index
475        if let Ok(bit_index) = identifier.parse::<u8>() {
476            if bit_index < 32 {
477                // Valid bit range for DINT
478                return Ok(TagPath::Bit {
479                    base_path: Box::new(base_path),
480                    bit_index,
481                });
482            }
483        }
484
485        // It's a member name
486        Ok(TagPath::Member {
487            base_path: Box::new(base_path),
488            member_name: identifier,
489        })
490    }
491
492    fn parse_identifier(&mut self) -> Result<String> {
493        let start = self.position;
494
495        while self.position < self.input.len() {
496            let ch = self.input.chars().nth(self.position).unwrap();
497            if ch.is_alphanumeric() || ch == '_' {
498                self.position += 1;
499            } else {
500                break;
501            }
502        }
503
504        if start == self.position {
505            return Err(EtherNetIpError::Protocol("Expected identifier".to_string()));
506        }
507
508        Ok(self.input[start..self.position].to_string())
509    }
510
511    fn parse_number(&mut self) -> Result<u32> {
512        let start = self.position;
513
514        while self.position < self.input.len() {
515            let ch = self.input.chars().nth(self.position).unwrap();
516            if ch.is_ascii_digit() {
517                self.position += 1;
518            } else {
519                break;
520            }
521        }
522
523        if start == self.position {
524            return Err(EtherNetIpError::Protocol("Expected number".to_string()));
525        }
526
527        self.input[start..self.position]
528            .parse()
529            .map_err(|_| EtherNetIpError::Protocol("Invalid number".to_string()))
530    }
531
532    fn peek_char(&self) -> Option<char> {
533        self.input.chars().nth(self.position)
534    }
535
536    fn consume_char(&mut self, expected: char) -> bool {
537        if self.peek_char() == Some(expected) {
538            self.position += 1;
539            true
540        } else {
541            false
542        }
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    #[test]
551    fn test_controller_scoped_tag() {
552        let path = TagPath::parse("MyTag").unwrap();
553        assert_eq!(
554            path,
555            TagPath::Controller {
556                tag_name: "MyTag".to_string()
557            }
558        );
559        assert_eq!(path.to_string(), "MyTag");
560    }
561
562    #[test]
563    fn test_program_scoped_tag() {
564        let path = TagPath::parse("Program:MainProgram.MyTag").unwrap();
565        assert_eq!(
566            path,
567            TagPath::Program {
568                program_name: "MainProgram".to_string(),
569                tag_name: "MyTag".to_string()
570            }
571        );
572        assert_eq!(path.to_string(), "Program:MainProgram.MyTag");
573        assert!(path.is_program_scoped());
574        assert_eq!(path.program_name(), Some("MainProgram".to_string()));
575    }
576
577    #[test]
578    fn test_array_access() {
579        let path = TagPath::parse("MyArray[5]").unwrap();
580        if let TagPath::Array { base_path, indices } = path {
581            assert_eq!(
582                *base_path,
583                TagPath::Controller {
584                    tag_name: "MyArray".to_string()
585                }
586            );
587            assert_eq!(indices, vec![5]);
588        } else {
589            panic!("Expected Array path");
590        }
591    }
592
593    #[test]
594    fn test_multi_dimensional_array() {
595        let path = TagPath::parse("Matrix[1,2,3]").unwrap();
596        if let TagPath::Array { base_path, indices } = path {
597            assert_eq!(
598                *base_path,
599                TagPath::Controller {
600                    tag_name: "Matrix".to_string()
601                }
602            );
603            assert_eq!(indices, vec![1, 2, 3]);
604        } else {
605            panic!("Expected Array path");
606        }
607    }
608
609    #[test]
610    fn test_bit_access() {
611        let path = TagPath::parse("StatusWord.15").unwrap();
612        if let TagPath::Bit {
613            base_path,
614            bit_index,
615        } = path
616        {
617            assert_eq!(
618                *base_path,
619                TagPath::Controller {
620                    tag_name: "StatusWord".to_string()
621                }
622            );
623            assert_eq!(bit_index, 15);
624        } else {
625            panic!("Expected Bit path");
626        }
627    }
628
629    #[test]
630    fn test_member_access() {
631        let path = TagPath::parse("MotorData.Speed").unwrap();
632        if let TagPath::Member {
633            base_path,
634            member_name,
635        } = path
636        {
637            assert_eq!(
638                *base_path,
639                TagPath::Controller {
640                    tag_name: "MotorData".to_string()
641                }
642            );
643            assert_eq!(member_name, "Speed");
644        } else {
645            panic!("Expected Member path");
646        }
647    }
648
649    #[test]
650    fn test_string_length() {
651        let path = TagPath::parse("MyString.LEN").unwrap();
652        if let TagPath::StringLength { base_path } = path {
653            assert_eq!(
654                *base_path,
655                TagPath::Controller {
656                    tag_name: "MyString".to_string()
657                }
658            );
659        } else {
660            panic!("Expected StringLength path");
661        }
662    }
663
664    #[test]
665    fn test_string_data() {
666        let path = TagPath::parse("MyString.DATA[5]").unwrap();
667        if let TagPath::StringData { base_path, index } = path {
668            assert_eq!(
669                *base_path,
670                TagPath::Controller {
671                    tag_name: "MyString".to_string()
672                }
673            );
674            assert_eq!(index, 5);
675        } else {
676            panic!("Expected StringData path");
677        }
678    }
679
680    #[test]
681    fn test_complex_nested_path() {
682        let path = TagPath::parse("Program:Safety.Devices[2].Status.15").unwrap();
683
684        // This should parse as:
685        // Program:Safety.Devices -> Array[2] -> Member(Status) -> Bit(15)
686        if let TagPath::Bit {
687            base_path,
688            bit_index,
689        } = path
690        {
691            assert_eq!(bit_index, 15);
692
693            if let TagPath::Member {
694                base_path,
695                member_name,
696            } = *base_path
697            {
698                assert_eq!(member_name, "Status");
699
700                if let TagPath::Array { base_path, indices } = *base_path {
701                    assert_eq!(indices, vec![2]);
702
703                    if let TagPath::Program {
704                        program_name,
705                        tag_name,
706                    } = *base_path
707                    {
708                        assert_eq!(program_name, "Safety");
709                        assert_eq!(tag_name, "Devices");
710                    } else {
711                        panic!("Expected Program path");
712                    }
713                } else {
714                    panic!("Expected Array path");
715                }
716            } else {
717                panic!("Expected Member path");
718            }
719        } else {
720            panic!("Expected Bit path");
721        }
722    }
723
724    #[test]
725    fn test_cip_path_generation() {
726        let path = TagPath::parse("MyTag").unwrap();
727        let cip_path = path.to_cip_path().unwrap();
728
729        // Should be: [0x91, 0x05, 'M', 'y', 'T', 'a', 'g', 0x00] (padded)
730        assert_eq!(cip_path[0], 0x91); // ANSI Extended Symbol Segment
731        assert_eq!(cip_path[1], 5); // Length of "MyTag"
732        assert_eq!(&cip_path[2..7], b"MyTag");
733        assert_eq!(cip_path[7], 0x00); // Padding
734    }
735
736    #[test]
737    fn test_array_cip_path_generation() {
738        let path = TagPath::parse("MyArray[5]").unwrap();
739        let cip_path = path.to_cip_path().unwrap();
740
741        // Should be: [0x91, 0x07, 'M', 'y', 'A', 'r', 'r', 'a', 'y', 0x00, 0x28, 0x05]
742        // Tag segment: 0x91, length 7, "MyArray", padding
743        assert_eq!(cip_path[0], 0x91); // ANSI Extended Symbol Segment
744        assert_eq!(cip_path[1], 7); // Length of "MyArray"
745        assert_eq!(&cip_path[2..9], b"MyArray");
746        assert_eq!(cip_path[9], 0x00); // Padding
747
748        // Array element segment: 0x28 (8-bit Element ID), index 5
749        // Reference: 1756-PM020, Pages 603-611 (Element ID Segment Format)
750        assert_eq!(cip_path[10], 0x28); // 8-bit Element ID segment
751        assert_eq!(cip_path[11], 0x05); // Index 5
752        assert_eq!(cip_path.len(), 12); // Total: 9 (tag) + 1 (padding) + 2 (element segment) = 12
753    }
754
755    #[test]
756    fn test_program_array_cip_path_generation() {
757        let path = TagPath::parse("Program:MainProgram.ArrayTest[0]").unwrap();
758        let cip_path = path.to_cip_path().unwrap();
759
760        tracing::debug!(
761            "Program array CIP path ({} bytes): {:02X?}",
762            cip_path.len(),
763            cip_path
764        );
765
766        // Verify structure:
767        // 1. Program segment: 0x91, length 19, "Program:MainProgram", padding
768        assert_eq!(cip_path[0], 0x91);
769        assert_eq!(cip_path[1], 19); // "Program:MainProgram".len()
770        assert_eq!(&cip_path[2..21], b"Program:MainProgram");
771        assert_eq!(cip_path[21], 0x00); // Padding after program segment
772
773        // 2. Tag segment: 0x91, length 9, "ArrayTest", padding
774        assert_eq!(cip_path[22], 0x91);
775        assert_eq!(cip_path[23], 9); // "ArrayTest".len()
776        assert_eq!(&cip_path[24..33], b"ArrayTest");
777        assert_eq!(cip_path[33], 0x00); // Padding after tag segment
778
779        // 3. Array element segment: 0x28 (8-bit Element ID), index 0
780        // Reference: 1756-PM020, Pages 603-611 (Element ID Segment Format)
781        assert_eq!(cip_path[34], 0x28); // 8-bit Element ID segment
782        assert_eq!(cip_path[35], 0x00); // Index 0
783
784        // Total should be 36 bytes (18 words)
785        // Program segment: 20 bytes + Tag segment: 12 bytes + Element segment: 2 bytes + padding: 2 bytes = 36 bytes
786        assert_eq!(cip_path.len(), 36);
787    }
788
789    #[test]
790    fn test_base_tag_name() {
791        let path = TagPath::parse("Program:Main.MotorData[1].Speed.15").unwrap();
792        assert_eq!(path.base_tag_name(), "MotorData");
793    }
794
795    #[test]
796    fn test_invalid_paths() {
797        assert!(TagPath::parse("").is_err());
798        assert!(TagPath::parse("Program:").is_err());
799        assert!(TagPath::parse("MyArray[").is_err());
800        assert!(TagPath::parse("MyArray]").is_err());
801        assert!(TagPath::parse("MyTag.").is_err());
802    }
803}