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 — only match if it's the complete segment
450        let remaining = &self.input[self.position..];
451        if remaining.starts_with("LEN")
452            && (remaining.len() == 3
453                || remaining.as_bytes()[3] == b'.'
454                || remaining.as_bytes()[3] == b'[')
455        {
456            self.position += 3;
457            return Ok(TagPath::StringLength {
458                base_path: Box::new(base_path),
459            });
460        }
461
462        if remaining.starts_with("DATA[") {
463            self.position += 5; // Skip "DATA["
464            let index = self.parse_number()?;
465            if !self.consume_char(']') {
466                return Err(EtherNetIpError::Protocol(
467                    "Expected ']' after DATA index".to_string(),
468                ));
469            }
470            return Ok(TagPath::StringData {
471                base_path: Box::new(base_path),
472                index,
473            });
474        }
475
476        // Parse identifier (could be member name or bit index)
477        let identifier = self.parse_identifier()?;
478
479        // Check if it's a numeric bit index
480        if let Ok(bit_index) = identifier.parse::<u8>() {
481            if bit_index < 32 {
482                // Valid bit range for DINT
483                return Ok(TagPath::Bit {
484                    base_path: Box::new(base_path),
485                    bit_index,
486                });
487            }
488        }
489
490        // It's a member name
491        Ok(TagPath::Member {
492            base_path: Box::new(base_path),
493            member_name: identifier,
494        })
495    }
496
497    fn parse_identifier(&mut self) -> Result<String> {
498        let start = self.position;
499
500        while self.position < self.input.len() {
501            let ch = self.input.as_bytes()[self.position];
502            if ch.is_ascii_alphanumeric() || ch == b'_' {
503                self.position += 1;
504            } else {
505                break;
506            }
507        }
508
509        if start == self.position {
510            return Err(EtherNetIpError::Protocol("Expected identifier".to_string()));
511        }
512
513        Ok(self.input[start..self.position].to_string())
514    }
515
516    fn parse_number(&mut self) -> Result<u32> {
517        let start = self.position;
518
519        while self.position < self.input.len() {
520            let ch = self.input.as_bytes()[self.position];
521            if ch.is_ascii_digit() {
522                self.position += 1;
523            } else {
524                break;
525            }
526        }
527
528        if start == self.position {
529            return Err(EtherNetIpError::Protocol("Expected number".to_string()));
530        }
531
532        self.input[start..self.position]
533            .parse()
534            .map_err(|_| EtherNetIpError::Protocol("Invalid number".to_string()))
535    }
536
537    fn peek_char(&self) -> Option<char> {
538        self.input
539            .as_bytes()
540            .get(self.position)
541            .map(|byte| *byte as char)
542    }
543
544    fn consume_char(&mut self, expected: char) -> bool {
545        if self.peek_char() == Some(expected) {
546            self.position += 1;
547            true
548        } else {
549            false
550        }
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn test_controller_scoped_tag() {
560        let path = TagPath::parse("MyTag").unwrap();
561        assert_eq!(
562            path,
563            TagPath::Controller {
564                tag_name: "MyTag".to_string()
565            }
566        );
567        assert_eq!(path.to_string(), "MyTag");
568    }
569
570    #[test]
571    fn test_program_scoped_tag() {
572        let path = TagPath::parse("Program:MainProgram.MyTag").unwrap();
573        assert_eq!(
574            path,
575            TagPath::Program {
576                program_name: "MainProgram".to_string(),
577                tag_name: "MyTag".to_string()
578            }
579        );
580        assert_eq!(path.to_string(), "Program:MainProgram.MyTag");
581        assert!(path.is_program_scoped());
582        assert_eq!(path.program_name(), Some("MainProgram".to_string()));
583    }
584
585    #[test]
586    fn test_array_access() {
587        let path = TagPath::parse("MyArray[5]").unwrap();
588        if let TagPath::Array { base_path, indices } = path {
589            assert_eq!(
590                *base_path,
591                TagPath::Controller {
592                    tag_name: "MyArray".to_string()
593                }
594            );
595            assert_eq!(indices, vec![5]);
596        } else {
597            panic!("Expected Array path");
598        }
599    }
600
601    #[test]
602    fn test_multi_dimensional_array() {
603        let path = TagPath::parse("Matrix[1,2,3]").unwrap();
604        if let TagPath::Array { base_path, indices } = path {
605            assert_eq!(
606                *base_path,
607                TagPath::Controller {
608                    tag_name: "Matrix".to_string()
609                }
610            );
611            assert_eq!(indices, vec![1, 2, 3]);
612        } else {
613            panic!("Expected Array path");
614        }
615    }
616
617    #[test]
618    fn test_bit_access() {
619        let path = TagPath::parse("StatusWord.15").unwrap();
620        if let TagPath::Bit {
621            base_path,
622            bit_index,
623        } = path
624        {
625            assert_eq!(
626                *base_path,
627                TagPath::Controller {
628                    tag_name: "StatusWord".to_string()
629                }
630            );
631            assert_eq!(bit_index, 15);
632        } else {
633            panic!("Expected Bit path");
634        }
635    }
636
637    #[test]
638    fn test_member_access() {
639        let path = TagPath::parse("MotorData.Speed").unwrap();
640        if let TagPath::Member {
641            base_path,
642            member_name,
643        } = path
644        {
645            assert_eq!(
646                *base_path,
647                TagPath::Controller {
648                    tag_name: "MotorData".to_string()
649                }
650            );
651            assert_eq!(member_name, "Speed");
652        } else {
653            panic!("Expected Member path");
654        }
655    }
656
657    #[test]
658    fn test_string_length() {
659        let path = TagPath::parse("MyString.LEN").unwrap();
660        if let TagPath::StringLength { base_path } = path {
661            assert_eq!(
662                *base_path,
663                TagPath::Controller {
664                    tag_name: "MyString".to_string()
665                }
666            );
667        } else {
668            panic!("Expected StringLength path");
669        }
670    }
671
672    #[test]
673    fn test_string_data() {
674        let path = TagPath::parse("MyString.DATA[5]").unwrap();
675        if let TagPath::StringData { base_path, index } = path {
676            assert_eq!(
677                *base_path,
678                TagPath::Controller {
679                    tag_name: "MyString".to_string()
680                }
681            );
682            assert_eq!(index, 5);
683        } else {
684            panic!("Expected StringData path");
685        }
686    }
687
688    #[test]
689    fn test_complex_nested_path() {
690        let path = TagPath::parse("Program:Safety.Devices[2].Status.15").unwrap();
691
692        // This should parse as:
693        // Program:Safety.Devices -> Array[2] -> Member(Status) -> Bit(15)
694        if let TagPath::Bit {
695            base_path,
696            bit_index,
697        } = path
698        {
699            assert_eq!(bit_index, 15);
700
701            if let TagPath::Member {
702                base_path,
703                member_name,
704            } = *base_path
705            {
706                assert_eq!(member_name, "Status");
707
708                if let TagPath::Array { base_path, indices } = *base_path {
709                    assert_eq!(indices, vec![2]);
710
711                    if let TagPath::Program {
712                        program_name,
713                        tag_name,
714                    } = *base_path
715                    {
716                        assert_eq!(program_name, "Safety");
717                        assert_eq!(tag_name, "Devices");
718                    } else {
719                        panic!("Expected Program path");
720                    }
721                } else {
722                    panic!("Expected Array path");
723                }
724            } else {
725                panic!("Expected Member path");
726            }
727        } else {
728            panic!("Expected Bit path");
729        }
730    }
731
732    #[test]
733    fn test_cip_path_generation() {
734        let path = TagPath::parse("MyTag").unwrap();
735        let cip_path = path.to_cip_path().unwrap();
736
737        // Should be: [0x91, 0x05, 'M', 'y', 'T', 'a', 'g', 0x00] (padded)
738        assert_eq!(cip_path[0], 0x91); // ANSI Extended Symbol Segment
739        assert_eq!(cip_path[1], 5); // Length of "MyTag"
740        assert_eq!(&cip_path[2..7], b"MyTag");
741        assert_eq!(cip_path[7], 0x00); // Padding
742    }
743
744    #[test]
745    fn test_array_cip_path_generation() {
746        let path = TagPath::parse("MyArray[5]").unwrap();
747        let cip_path = path.to_cip_path().unwrap();
748
749        // Should be: [0x91, 0x07, 'M', 'y', 'A', 'r', 'r', 'a', 'y', 0x00, 0x28, 0x05]
750        // Tag segment: 0x91, length 7, "MyArray", padding
751        assert_eq!(cip_path[0], 0x91); // ANSI Extended Symbol Segment
752        assert_eq!(cip_path[1], 7); // Length of "MyArray"
753        assert_eq!(&cip_path[2..9], b"MyArray");
754        assert_eq!(cip_path[9], 0x00); // Padding
755
756        // Array element segment: 0x28 (8-bit Element ID), index 5
757        // Reference: 1756-PM020, Pages 603-611 (Element ID Segment Format)
758        assert_eq!(cip_path[10], 0x28); // 8-bit Element ID segment
759        assert_eq!(cip_path[11], 0x05); // Index 5
760        assert_eq!(cip_path.len(), 12); // Total: 9 (tag) + 1 (padding) + 2 (element segment) = 12
761    }
762
763    #[test]
764    fn test_program_array_cip_path_generation() {
765        let path = TagPath::parse("Program:MainProgram.ArrayTest[0]").unwrap();
766        let cip_path = path.to_cip_path().unwrap();
767
768        tracing::debug!(
769            "Program array CIP path ({} bytes): {:02X?}",
770            cip_path.len(),
771            cip_path
772        );
773
774        // Verify structure:
775        // 1. Program segment: 0x91, length 19, "Program:MainProgram", padding
776        assert_eq!(cip_path[0], 0x91);
777        assert_eq!(cip_path[1], 19); // "Program:MainProgram".len()
778        assert_eq!(&cip_path[2..21], b"Program:MainProgram");
779        assert_eq!(cip_path[21], 0x00); // Padding after program segment
780
781        // 2. Tag segment: 0x91, length 9, "ArrayTest", padding
782        assert_eq!(cip_path[22], 0x91);
783        assert_eq!(cip_path[23], 9); // "ArrayTest".len()
784        assert_eq!(&cip_path[24..33], b"ArrayTest");
785        assert_eq!(cip_path[33], 0x00); // Padding after tag segment
786
787        // 3. Array element segment: 0x28 (8-bit Element ID), index 0
788        // Reference: 1756-PM020, Pages 603-611 (Element ID Segment Format)
789        assert_eq!(cip_path[34], 0x28); // 8-bit Element ID segment
790        assert_eq!(cip_path[35], 0x00); // Index 0
791
792        // Total should be 36 bytes (18 words)
793        // Program segment: 20 bytes + Tag segment: 12 bytes + Element segment: 2 bytes + padding: 2 bytes = 36 bytes
794        assert_eq!(cip_path.len(), 36);
795    }
796
797    #[test]
798    fn test_base_tag_name() {
799        let path = TagPath::parse("Program:Main.MotorData[1].Speed.15").unwrap();
800        assert_eq!(path.base_tag_name(), "MotorData");
801    }
802
803    #[test]
804    fn test_invalid_paths() {
805        assert!(TagPath::parse("").is_err());
806        assert!(TagPath::parse("Program:").is_err());
807        assert!(TagPath::parse("MyArray[").is_err());
808        assert!(TagPath::parse("MyArray]").is_err());
809        assert!(TagPath::parse("MyTag.").is_err());
810    }
811}