Skip to main content

rust_ethernet_ip/
tag_manager.rs

1use crate::EipClient;
2use crate::error::{EtherNetIpError, Result};
3use crate::udt::UdtDefinition;
4use std::collections::HashMap;
5use std::sync::{LazyLock, RwLock};
6use std::time::{Duration, Instant};
7use tracing;
8
9static TAG_NAME_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
10    regex::Regex::new(r"^(?:Program:[A-Za-z_][A-Za-z0-9_]*\.)?[A-Za-z_][A-Za-z0-9_]*(?:\[\d+\])?(?:\.[A-Za-z_][A-Za-z0-9_]*(?:\[\d+\])?)*$")
11        .expect("tag name regex pattern is a valid literal")
12});
13
14/// Represents the scope of a tag in the PLC
15#[derive(Debug, Clone, PartialEq)]
16pub enum TagScope {
17    /// Tag in the controller scope
18    Controller,
19    /// Tag in a program scope
20    Program(String),
21    Global,
22    Local,
23}
24
25/// Array information for tags
26#[derive(Debug, Clone)]
27pub struct ArrayInfo {
28    pub dimensions: Vec<u32>,
29    pub element_count: u32,
30}
31
32/// Metadata for a PLC tag
33#[derive(Debug, Clone)]
34pub struct TagMetadata {
35    /// The data type of the tag
36    pub data_type: u16,
37    /// Size of the tag in bytes
38    pub size: u32,
39    /// Whether the tag is an array
40    pub is_array: bool,
41    /// Array dimensions if applicable
42    pub dimensions: Vec<u32>,
43    /// Access permissions for the tag
44    pub permissions: TagPermissions,
45    /// Scope of the tag
46    pub scope: TagScope,
47    /// Last time this tag was accessed
48    pub last_access: Instant,
49    pub array_info: Option<ArrayInfo>,
50    pub last_updated: Instant,
51}
52
53/// Access permissions for a tag
54#[derive(Debug, Clone, PartialEq)]
55pub struct TagPermissions {
56    /// Whether the tag can be read
57    pub readable: bool,
58    /// Whether the tag can be written
59    pub writable: bool,
60}
61
62impl TagMetadata {
63    /// Returns true if this tag is a structure/UDT
64    pub fn is_structure(&self) -> bool {
65        is_structure_type_word(self.data_type)
66    }
67}
68
69fn is_structure_type_word(data_type: u16) -> bool {
70    let type_code = data_type & 0x0fff;
71    (data_type & 0x8000) != 0 || (0x00A0..=0x00AF).contains(&type_code) || type_code == 0x02A0
72}
73
74/// Cache for PLC tags with automatic expiration
75#[derive(Debug)]
76#[deprecated(
77    since = "1.2.0",
78    note = "TagCache was never wired into live discovery; use TagManager's built-in cache instead. The type will be removed in 2.0."
79)]
80pub struct TagCache {
81    /// Map of tag names to their metadata
82    tags: HashMap<String, (TagMetadata, Instant)>,
83    /// Cache expiration time
84    expiration: Duration,
85}
86
87#[expect(
88    deprecated,
89    reason = "CODEX-AQ keeps the compatibility implementation until 2.0 removal"
90)]
91impl TagCache {
92    /// Creates a new tag cache with the specified expiration time
93    pub fn new(expiration: Duration) -> Self {
94        Self {
95            tags: HashMap::new(),
96            expiration,
97        }
98    }
99
100    /// Updates or adds a tag to the cache
101    pub fn update_tag(&mut self, name: String, metadata: TagMetadata) {
102        self.tags.insert(name, (metadata, Instant::now()));
103    }
104
105    /// Gets a tag from the cache if it exists and hasn't expired
106    pub fn get_tag(&self, name: &str) -> Option<&TagMetadata> {
107        if let Some((metadata, timestamp)) = self.tags.get(name)
108            && timestamp.elapsed() < self.expiration
109        {
110            return Some(metadata);
111        }
112        None
113    }
114
115    /// Removes expired tags from the cache
116    pub fn cleanup(&mut self) {
117        self.tags
118            .retain(|_, (_, timestamp)| timestamp.elapsed() < self.expiration);
119    }
120}
121
122/// Manager for PLC tag discovery and caching
123#[derive(Debug)]
124pub struct TagManager {
125    pub cache: RwLock<HashMap<String, TagMetadata>>,
126    cache_duration: Duration,
127    pub udt_definitions: RwLock<HashMap<String, UdtDefinition>>,
128}
129
130impl TagManager {
131    pub fn new() -> Self {
132        Self {
133            cache: RwLock::new(HashMap::new()),
134            cache_duration: Duration::from_secs(300), // 5 minutes
135            udt_definitions: RwLock::new(HashMap::new()),
136        }
137    }
138
139    pub async fn get_metadata(&self, tag_name: &str) -> Result<Option<TagMetadata>> {
140        let cache = self.cache.read()?;
141        Ok(cache.get(tag_name).and_then(|metadata| {
142            if metadata.last_updated.elapsed() < self.cache_duration {
143                Some(metadata.clone())
144            } else {
145                None
146            }
147        }))
148    }
149
150    pub async fn update_metadata(&self, tag_name: String, metadata: TagMetadata) -> Result<()> {
151        self.cache.write()?.insert(tag_name, metadata);
152        Ok(())
153    }
154
155    pub async fn validate_tag(
156        &self,
157        tag_name: &str,
158        required_permissions: &TagPermissions,
159    ) -> Result<()> {
160        if let Some(metadata) = self.get_metadata(tag_name).await? {
161            if !metadata.permissions.readable && required_permissions.readable {
162                return Err(EtherNetIpError::Permission(format!(
163                    "Tag '{tag_name}' is not readable"
164                )));
165            }
166            if !metadata.permissions.writable && required_permissions.writable {
167                return Err(EtherNetIpError::Permission(format!(
168                    "Tag '{tag_name}' is not writable"
169                )));
170            }
171            Ok(())
172        } else {
173            Err(EtherNetIpError::Tag(format!("Tag '{tag_name}' not found")))
174        }
175    }
176
177    pub async fn clear_cache(&self) -> Result<()> {
178        self.cache.write()?.clear();
179        Ok(())
180    }
181
182    pub async fn remove_stale_entries(&self) -> Result<()> {
183        self.cache
184            .write()?
185            .retain(|_, metadata| metadata.last_updated.elapsed() < self.cache_duration);
186        Ok(())
187    }
188
189    pub async fn discover_tags(&self, client: &mut EipClient) -> Result<()> {
190        let response = client
191            .send_cip_request(&client.build_list_tags_request())
192            .await?;
193        let tags = self.parse_tag_list(&response)?;
194
195        // Perform hierarchical discovery for structures/UDTs
196        let mut all_tags = Vec::new();
197        for (name, metadata) in tags {
198            all_tags.push((name, metadata));
199        }
200
201        // Discover nested tags for structures
202        let hierarchical_tags = self.discover_hierarchical_tags(client, &all_tags).await?;
203
204        let mut cache = self.cache.write()?;
205        for (name, metadata) in hierarchical_tags {
206            cache.insert(name, metadata);
207        }
208        Ok(())
209    }
210
211    /// Discovers hierarchical tags by drilling down into structures and UDTs
212    async fn discover_hierarchical_tags(
213        &self,
214        client: &mut EipClient,
215        base_tags: &[(String, TagMetadata)],
216    ) -> Result<Vec<(String, TagMetadata)>> {
217        let mut all_tags = Vec::new();
218        let mut tag_names = std::collections::HashSet::new();
219
220        // Add base tags first
221        for (name, metadata) in base_tags {
222            if self.validate_tag_name(name) {
223                all_tags.push((name.clone(), metadata.clone()));
224                tag_names.insert(name.clone());
225            }
226        }
227
228        // Process each tag for hierarchical discovery
229        for (name, metadata) in base_tags {
230            if metadata.is_structure() && !metadata.is_array {
231                // This is a structure/UDT, try to discover its members
232                if let Ok(members) = self.discover_udt_members(client, name).await {
233                    for (member_name, member_metadata) in members {
234                        let full_name = format!("{}.{}", name, member_name);
235                        if self.validate_tag_name(&full_name) && !tag_names.contains(&full_name) {
236                            all_tags.push((full_name.clone(), member_metadata.clone()));
237                            tag_names.insert(full_name.clone());
238
239                            // Recursively discover nested structures
240                            if member_metadata.is_structure()
241                                && !member_metadata.is_array
242                                && let Ok(nested_members) =
243                                    self.discover_udt_members(client, &full_name).await
244                            {
245                                for (nested_name, nested_metadata) in nested_members {
246                                    let nested_full_name = format!("{}.{}", full_name, nested_name);
247                                    if self.validate_tag_name(&nested_full_name)
248                                        && !tag_names.contains(&nested_full_name)
249                                    {
250                                        all_tags.push((nested_full_name.clone(), nested_metadata));
251                                        tag_names.insert(nested_full_name);
252                                    }
253                                }
254                            }
255                        }
256                    }
257                }
258            }
259        }
260
261        tracing::debug!(
262            "Discovered {} total tags (including hierarchical)",
263            all_tags.len()
264        );
265        Ok(all_tags)
266    }
267
268    /// Discovers members of a UDT/structure
269    pub async fn discover_udt_members(
270        &self,
271        client: &mut EipClient,
272        udt_name: &str,
273    ) -> Result<Vec<(String, TagMetadata)>> {
274        tracing::debug!("Discovering UDT members for: {}", udt_name);
275
276        let udt_definition = client.get_udt_definition(udt_name).await?;
277
278        {
279            let mut definitions = self.udt_definitions.write()?;
280            definitions.insert(udt_name.to_string(), udt_definition.clone());
281        }
282
283        let mut members = Vec::new();
284        for member in &udt_definition.members {
285            let full_name = format!("{}.{}", udt_name, member.name);
286            if !self.validate_tag_name(&full_name) {
287                tracing::warn!("Skipping invalid UDT member path: {}", full_name);
288                continue;
289            }
290
291            let metadata = TagMetadata {
292                data_type: member.data_type,
293                scope: TagScope::Controller,
294                permissions: TagPermissions {
295                    readable: true,
296                    writable: true,
297                },
298                is_array: false,
299                dimensions: Vec::new(),
300                last_access: Instant::now(),
301                size: member.size,
302                array_info: None,
303                last_updated: Instant::now(),
304            };
305
306            tracing::trace!(
307                "Found UDT member: {} (Type: 0x{:04X})",
308                full_name,
309                member.data_type
310            );
311            members.push((member.name.clone(), metadata));
312        }
313
314        Ok(members)
315    }
316
317    /// Deprecated compatibility stub retained for 1.x SemVer.
318    #[deprecated(
319        since = "1.2.0",
320        note = "This method fabricated an invalid UDT request. Use EipClient::get_udt_definition or EipClient::discover_udt_members instead. It will be removed in 2.0."
321    )]
322    pub fn build_udt_definition_request(&self, _udt_name: &str) -> Result<Vec<u8>> {
323        Err(EtherNetIpError::Unsupported {
324            api: "TagManager::build_udt_definition_request",
325            reason: "the old request builder emitted an invalid Read Tag shape; use EipClient::get_udt_definition or EipClient::discover_udt_members",
326        })
327    }
328
329    /// Deprecated compatibility stub retained for 1.x SemVer.
330    #[deprecated(
331        since = "1.2.0",
332        note = "This method fabricated UDT members from arbitrary bytes. Use EipClient::get_udt_definition or EipClient::discover_udt_members instead. It will be removed in 2.0."
333    )]
334    pub fn parse_udt_definition_response(
335        &self,
336        _response: &[u8],
337        _udt_name: &str,
338    ) -> Result<UdtDefinition> {
339        Err(EtherNetIpError::Unsupported {
340            api: "TagManager::parse_udt_definition_response",
341            reason: "the old parser invented member names and fallback fields; use EipClient::get_udt_definition or EipClient::discover_udt_members",
342        })
343    }
344
345    /// Validates tag name similar to the contributor's JavaScript validation
346    fn validate_tag_name(&self, tag_name: &str) -> bool {
347        if tag_name.is_empty() || tag_name.trim().is_empty() {
348            return false;
349        }
350
351        // Check for valid characters: alphanumeric, dots, underscores
352        if !TAG_NAME_RE.is_match(tag_name) {
353            return false;
354        }
355
356        // Check for invalid patterns
357        if tag_name.starts_with(char::is_numeric) {
358            return false;
359        }
360
361        if tag_name.contains("__") || tag_name.contains("..") {
362            return false;
363        }
364
365        true
366    }
367
368    /// Gets a cached UDT definition
369    pub fn get_udt_definition_cached(&self, udt_name: &str) -> Option<UdtDefinition> {
370        self.udt_definitions
371            .read()
372            .ok()
373            .and_then(|definitions| definitions.get(udt_name).cloned())
374    }
375
376    /// Lists all cached UDT definitions
377    pub fn list_udt_definitions(&self) -> Vec<String> {
378        self.udt_definitions
379            .read()
380            .map(|definitions| definitions.keys().cloned().collect())
381            .unwrap_or_default()
382    }
383
384    /// Clears UDT definition cache
385    pub fn clear_udt_cache(&self) {
386        if let Ok(mut definitions) = self.udt_definitions.write() {
387            definitions.clear();
388        }
389    }
390
391    pub fn parse_tag_list(&self, response: &[u8]) -> Result<Vec<(String, TagMetadata)>> {
392        tracing::trace!(
393            "Raw tag list response ({} bytes): {:02X?}",
394            response.len(),
395            response
396        );
397
398        // Check if this is a CIP error response
399        if response.len() >= 3 {
400            let service_reply = response[0];
401            let general_status = response[2];
402
403            // Check for error responses
404            if general_status != 0x00 {
405                // This is an error response, not a tag list
406                let error_msg = match general_status {
407                    0x01 => "Connection failure - Tag discovery may not be supported on this PLC",
408                    0x04 => "Path segment error",
409                    0x05 => "Path destination unknown",
410                    0x16 => "Object does not exist",
411                    _ => "Unknown CIP error",
412                };
413                return Err(crate::error::EtherNetIpError::Protocol(format!(
414                    "CIP Error 0x{:02X} during tag discovery: {}. Some PLCs do not support tag discovery. Try reading tags directly by name.",
415                    general_status, error_msg
416                )));
417            }
418
419            // Verify this is a Get Instance Attribute List response (0xD5 = 0x55 + 0x80)
420            if service_reply != 0xD5 && service_reply != 0x55 {
421                // Might be a different service code, but if status is 0x00, try to parse anyway
422                if general_status == 0x00 {
423                    tracing::warn!(
424                        "Unexpected service reply 0x{:02X}, but status is 0x00, attempting to parse",
425                        service_reply
426                    );
427                }
428            }
429        }
430
431        let mut tags = Vec::new();
432
433        // Allen-Bradley tag list response format:
434        // [ServiceReply(1)][Reserved(1)][Status(1)][AdditionalStatusSize(1)][ItemCount(4)][Items...]
435        // Each item: [InstanceID(4)][NameLength(2)][Name][Type(2)][AdditionalData...]
436
437        if response.len() < 8 {
438            return Err(crate::error::EtherNetIpError::Protocol(
439                "Response too short for tag list".to_string(),
440            ));
441        }
442
443        // Any additional-status words sit between AdditionalStatusSize (byte 3)
444        // and ItemCount, so the count is NOT at a fixed offset of 4 — it starts
445        // at 4 + additional_status_size*2. On a success reply the size is 0.
446        let additional_status_bytes = response[3] as usize * 2;
447        let item_count_at = 4 + additional_status_bytes;
448        if response.len() < item_count_at + 4 {
449            return Err(crate::error::EtherNetIpError::Protocol(
450                "Response too short for tag list item count".to_string(),
451            ));
452        }
453        let item_count = u32::from_le_bytes([
454            response[item_count_at],
455            response[item_count_at + 1],
456            response[item_count_at + 2],
457            response[item_count_at + 3],
458        ]);
459        tracing::debug!("Detected item count: {}", item_count);
460
461        // Items begin immediately after the 4-byte ItemCount.
462        let mut offset = item_count_at + 4;
463
464        // Parse each advertised tag entry. Truncated pages are treated as malformed
465        // instead of scanning for byte patterns that may occur inside valid names.
466        for item_index in 0..item_count {
467            // Check if we have enough bytes for instance ID
468            if offset + 4 > response.len() {
469                return Err(crate::error::EtherNetIpError::Protocol(format!(
470                    "Tag list ended before instance ID for item {item_index} at offset {offset}"
471                )));
472            }
473
474            let instance_id = u32::from_le_bytes([
475                response[offset],
476                response[offset + 1],
477                response[offset + 2],
478                response[offset + 3],
479            ]);
480            offset += 4;
481
482            // Check if we have enough bytes for name length
483            if offset + 2 > response.len() {
484                return Err(crate::error::EtherNetIpError::Protocol(format!(
485                    "Tag list ended before name length for item {item_index} at offset {offset}"
486                )));
487            }
488
489            let name_length = u16::from_le_bytes([response[offset], response[offset + 1]]) as usize;
490            offset += 2;
491
492            if name_length > 1000 || name_length == 0 {
493                return Err(crate::error::EtherNetIpError::Protocol(format!(
494                    "Invalid tag-list name length {} for item {} at offset {}",
495                    name_length,
496                    item_index,
497                    offset - 2
498                )));
499            }
500
501            // Check if we have enough bytes for the tag name
502            if offset
503                .checked_add(name_length)
504                .is_none_or(|end| end > response.len())
505            {
506                return Err(crate::error::EtherNetIpError::Protocol(format!(
507                    "Not enough bytes for tag name at offset {} (need {}, have {})",
508                    offset,
509                    name_length,
510                    response.len() - offset
511                )));
512            }
513
514            let name = String::from_utf8_lossy(&response[offset..offset + name_length]).to_string();
515            offset += name_length;
516
517            // Check if we have enough bytes for tag type
518            if offset + 2 > response.len() {
519                return Err(crate::error::EtherNetIpError::Protocol(format!(
520                    "Tag list ended before type word for item {item_index} at offset {offset}"
521                )));
522            }
523
524            let tag_type = u16::from_le_bytes([response[offset], response[offset + 1]]);
525            offset += 2;
526
527            // Parse tag type information (similar to Node.js implementation)
528            let (type_code, is_structure, array_dims, _reserved) = self.parse_tag_type(tag_type);
529
530            let is_array = array_dims > 0;
531            let dimensions = Vec::new();
532            let array_info = None;
533
534            // Filter tags by type (similar to TypeScript implementation)
535            if !self.is_valid_tag_type(type_code) {
536                tracing::debug!(
537                    "Skipping tag {} - unsupported type 0x{:04X}",
538                    name,
539                    type_code
540                );
541                continue;
542            }
543
544            let metadata = TagMetadata {
545                data_type: type_code,
546                scope: TagScope::Controller,
547                permissions: TagPermissions {
548                    readable: true,
549                    writable: true,
550                },
551                is_array,
552                dimensions,
553                last_access: Instant::now(),
554                size: 0,
555                array_info,
556                last_updated: Instant::now(),
557            };
558
559            tracing::trace!(
560                "Parsed tag: {} (ID: {}, Type: 0x{:04X}, Structure: {})",
561                name,
562                instance_id,
563                type_code,
564                is_structure
565            );
566
567            tags.push((name, metadata));
568        }
569
570        tracing::debug!("Parsed {} tags from response", tags.len());
571        Ok(tags)
572    }
573
574    /// Parse tag type information from the raw type value
575    fn parse_tag_type(&self, tag_type: u16) -> (u16, bool, u8, bool) {
576        let type_code = if (tag_type & 0x00ff) == 0xc1 {
577            0x00c1
578        } else {
579            tag_type & 0x0fff
580        };
581
582        let is_structure = is_structure_type_word(tag_type) || is_structure_type_word(type_code);
583        let array_dims = ((tag_type & 0x6000) >> 13) as u8;
584        let reserved = (tag_type & 0x1000) != 0;
585
586        (type_code, is_structure, array_dims, reserved)
587    }
588
589    /// Check if a tag type is valid for reading/writing (similar to TypeScript implementation)
590    fn is_valid_tag_type(&self, type_code: u16) -> bool {
591        match type_code {
592            0x00C1 => true, // BOOL
593            0x00C2 => true, // SINT
594            0x00C3 => true, // INT
595            0x00C4 => true, // DINT
596            0x00C5 => true, // LINT
597            0x00C6 => true, // USINT
598            0x00C7 => true, // UINT
599            0x00C8 => true, // UDINT
600            0x00C9 => true, // ULINT
601            0x00CA => true, // REAL
602            0x00CB => true, // LREAL
603            0x00CE => true, // STRING
604            _ => is_structure_type_word(type_code),
605        }
606    }
607
608    /// Recursively drill down into UDT structures (similar to TypeScript drillDown function)
609    pub async fn drill_down_tags(
610        &self,
611        base_tags: &[(String, TagMetadata)],
612    ) -> Result<Vec<(String, TagMetadata)>> {
613        let mut all_tags = Vec::new();
614        let mut tag_names = std::collections::HashSet::new();
615
616        // Process each base tag
617        for (tag_name, metadata) in base_tags {
618            self.drill_down_recursive(&mut all_tags, &mut tag_names, tag_name, metadata, "")?;
619        }
620
621        tracing::debug!(
622            "Drill down completed: {} total tags discovered",
623            all_tags.len()
624        );
625        Ok(all_tags)
626    }
627
628    /// Recursive drill down helper (similar to TypeScript drillDown function)
629    fn drill_down_recursive(
630        &self,
631        all_tags: &mut Vec<(String, TagMetadata)>,
632        tag_names: &mut std::collections::HashSet<String>,
633        tag_name: &str,
634        metadata: &TagMetadata,
635        previous_name: &str,
636    ) -> Result<()> {
637        // Skip arrays (similar to TypeScript: if (tagInfo.type.arrayDims > 0) return;)
638        if metadata.is_array {
639            return Ok(());
640        }
641
642        let new_name = if previous_name.is_empty() {
643            tag_name.to_string()
644        } else {
645            format!("{}.{}", previous_name, tag_name)
646        };
647
648        // Check if this is a structure/UDT (similar to TypeScript structure check)
649        if metadata.is_structure() && !metadata.is_array {
650            // For now, just add the structure tag itself
651            // UDT member discovery would require async calls which we'll handle separately
652            if self.validate_tag_name(&new_name) && !tag_names.contains(&new_name) {
653                all_tags.push((new_name.clone(), metadata.clone()));
654                tag_names.insert(new_name);
655            }
656        } else {
657            // This is a leaf tag - add it if it's a valid type
658            if self.is_valid_tag_type(metadata.data_type)
659                && self.validate_tag_name(&new_name)
660                && !tag_names.contains(&new_name)
661            {
662                all_tags.push((new_name.clone(), metadata.clone()));
663                tag_names.insert(new_name);
664            }
665        }
666
667        Ok(())
668    }
669}
670
671impl Default for TagManager {
672    fn default() -> Self {
673        Self::new()
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use crate::udt::UdtMember;
681
682    #[test]
683    #[expect(
684        deprecated,
685        reason = "CODEX-AQ keeps TagCache covered until 2.0 removal"
686    )]
687    fn test_tag_cache_expiration() {
688        let mut cache = TagCache::new(Duration::from_secs(1));
689        let metadata = TagMetadata {
690            data_type: 0x00C1,
691            size: 1,
692            is_array: false,
693            dimensions: vec![],
694            permissions: TagPermissions {
695                readable: true,
696                writable: true,
697            },
698            scope: TagScope::Controller,
699            last_access: Instant::now(),
700            array_info: None,
701            last_updated: Instant::now(),
702        };
703
704        cache.update_tag("TestTag".to_string(), metadata);
705        assert!(cache.get_tag("TestTag").is_some());
706
707        // Wait for expiration
708        std::thread::sleep(Duration::from_secs(2));
709        assert!(cache.get_tag("TestTag").is_none());
710    }
711
712    #[test]
713    fn test_tag_metadata_is_structure() {
714        // Test BOOL (not structure)
715        let bool_metadata = TagMetadata {
716            data_type: 0x00C1,
717            size: 1,
718            is_array: false,
719            dimensions: vec![],
720            permissions: TagPermissions {
721                readable: true,
722                writable: true,
723            },
724            scope: TagScope::Controller,
725            last_access: Instant::now(),
726            array_info: None,
727            last_updated: Instant::now(),
728        };
729        assert!(!bool_metadata.is_structure());
730
731        // Test DINT (not structure)
732        let dint_metadata = TagMetadata {
733            data_type: 0x00C4,
734            size: 4,
735            is_array: false,
736            dimensions: vec![],
737            permissions: TagPermissions {
738                readable: true,
739                writable: true,
740            },
741            scope: TagScope::Controller,
742            last_access: Instant::now(),
743            array_info: None,
744            last_updated: Instant::now(),
745        };
746        assert!(!dint_metadata.is_structure());
747
748        // Test UDT (structure)
749        let udt_metadata = TagMetadata {
750            data_type: 0x00A0,
751            size: 20,
752            is_array: false,
753            dimensions: vec![],
754            permissions: TagPermissions {
755                readable: true,
756                writable: true,
757            },
758            scope: TagScope::Controller,
759            last_access: Instant::now(),
760            array_info: None,
761            last_updated: Instant::now(),
762        };
763        assert!(udt_metadata.is_structure());
764    }
765
766    #[test]
767    fn test_validate_tag_name() {
768        let tag_manager = TagManager::new();
769
770        // Valid tag names
771        assert!(tag_manager.validate_tag_name("ValidTag"));
772        assert!(tag_manager.validate_tag_name("Valid_Tag"));
773        assert!(tag_manager.validate_tag_name("Valid.Tag"));
774        assert!(tag_manager.validate_tag_name("Valid123"));
775        assert!(tag_manager.validate_tag_name("Valid_Tag123"));
776        assert!(tag_manager.validate_tag_name("Valid.Tag123"));
777        assert!(tag_manager.validate_tag_name("_Tag"));
778        assert!(tag_manager.validate_tag_name("Program:Main.Tag"));
779        assert!(tag_manager.validate_tag_name("Arr[3]"));
780        assert!(tag_manager.validate_tag_name("Program:Main.Arr[3].Member"));
781
782        // Invalid tag names
783        assert!(!tag_manager.validate_tag_name("")); // Empty
784        assert!(!tag_manager.validate_tag_name("   ")); // Whitespace only
785        assert!(!tag_manager.validate_tag_name("123Invalid")); // Starts with number
786        assert!(!tag_manager.validate_tag_name("Invalid..Tag")); // Double dot
787        assert!(!tag_manager.validate_tag_name("Invalid-Tag")); // Invalid character
788        assert!(!tag_manager.validate_tag_name("Invalid Tag")); // Space
789        assert!(!tag_manager.validate_tag_name("Invalid@Tag")); // Invalid character
790    }
791
792    #[test]
793    fn test_parse_tag_type() {
794        let tag_manager = TagManager::new();
795
796        // Test BOOL type
797        let (type_code, is_structure, array_dims, reserved) = tag_manager.parse_tag_type(0x00C1);
798        assert_eq!(type_code, 0x00C1);
799        assert!(!is_structure);
800        assert_eq!(array_dims, 0);
801        assert!(!reserved);
802
803        // Test DINT type
804        let (type_code, is_structure, array_dims, reserved) = tag_manager.parse_tag_type(0x00C4);
805        assert_eq!(type_code, 0x00C4);
806        assert!(!is_structure);
807        assert_eq!(array_dims, 0);
808        assert!(!reserved);
809
810        // Test structure type
811        let (type_code, is_structure, array_dims, reserved) = tag_manager.parse_tag_type(0x80A0);
812        assert_eq!(type_code, 0x00A0);
813        assert!(is_structure);
814        assert_eq!(array_dims, 0);
815        assert!(!reserved);
816
817        let (type_code, is_structure, array_dims, reserved) = tag_manager.parse_tag_type(0x82A0);
818        assert_eq!(type_code, 0x02A0);
819        assert!(is_structure);
820        assert_eq!(array_dims, 0);
821        assert!(!reserved);
822
823        // Test array type
824        let (type_code, is_structure, array_dims, reserved) = tag_manager.parse_tag_type(0x20C4);
825        assert_eq!(type_code, 0x00C4);
826        assert!(!is_structure);
827        assert_eq!(array_dims, 1);
828        assert!(!reserved);
829
830        // Test multi-dimensional array
831        let (type_code, is_structure, array_dims, reserved) = tag_manager.parse_tag_type(0x40C4);
832        assert_eq!(type_code, 0x00C4);
833        assert!(!is_structure);
834        assert_eq!(array_dims, 2);
835        assert!(!reserved);
836    }
837
838    #[test]
839    fn test_udt_definition_caching() {
840        let tag_manager = TagManager::new();
841
842        // Initially no UDT definitions
843        assert!(tag_manager.list_udt_definitions().is_empty());
844
845        // Create a test UDT definition
846        let udt_def = UdtDefinition {
847            name: "TestUDT".to_string(),
848            members: vec![
849                UdtMember {
850                    name: "Value1".to_string(),
851                    data_type: 0x00C1,
852                    offset: 0,
853                    size: 1,
854                },
855                UdtMember {
856                    name: "Value2".to_string(),
857                    data_type: 0x00C4,
858                    offset: 4,
859                    size: 4,
860                },
861            ],
862        };
863
864        // Manually add to cache (simulating discovery)
865        {
866            let mut definitions = tag_manager
867                .udt_definitions
868                .write()
869                .expect("test UDT definition cache lock should not be poisoned");
870            definitions.insert("TestUDT".to_string(), udt_def);
871        }
872
873        // Should now be able to retrieve it
874        let retrieved = tag_manager.get_udt_definition_cached("TestUDT");
875        assert!(retrieved.is_some());
876        let retrieved = retrieved.unwrap();
877        assert_eq!(retrieved.name, "TestUDT");
878        assert_eq!(retrieved.members.len(), 2);
879
880        // Should be in the list
881        let udt_list = tag_manager.list_udt_definitions();
882        assert_eq!(udt_list.len(), 1);
883        assert_eq!(udt_list[0], "TestUDT");
884
885        // Clear cache
886        tag_manager.clear_udt_cache();
887        assert!(tag_manager.list_udt_definitions().is_empty());
888        assert!(tag_manager.get_udt_definition_cached("TestUDT").is_none());
889    }
890
891    #[test]
892    fn test_parse_tag_list_with_invalid_data() {
893        let tag_manager = TagManager::new();
894
895        // Test with response that has invalid name length
896        let invalid_response = [
897            0x00, 0x00, 0x00, 0x00, // Instance ID
898            0xFF, 0xFF, // Invalid name length (65535)
899            0x00, 0x00, 0x00, 0x00, // Some data
900        ];
901
902        let result = tag_manager.parse_tag_list(&invalid_response);
903        assert!(result.is_err());
904    }
905
906    #[test]
907    fn test_parse_tag_list_with_valid_data() {
908        let tag_manager = TagManager::new();
909
910        let valid_response = [
911            0xD5, 0x00, 0x00, 0x00, // Service, reserved, success, no additional status
912            0x01, 0x00, 0x00, 0x00, // Item count
913            0x2A, 0x00, 0x00, 0x00, // Instance ID
914            0x09, 0x00, // Name length (9)
915            b'M', b'o', b't', b'o', b'r', b'D', b'a', b't', b'a', // "MotorData"
916            0xC4, 0x00, // DINT type
917        ];
918
919        let tags = tag_manager.parse_tag_list(&valid_response).unwrap();
920        assert_eq!(tags.len(), 1);
921        assert_eq!(tags[0].0, "MotorData");
922        assert_eq!(tags[0].1.data_type, 0x00C4);
923        assert!(!tags[0].1.is_array);
924        assert!(tags[0].1.dimensions.is_empty());
925        assert!(tags[0].1.array_info.is_none());
926    }
927
928    #[test]
929    fn test_tag_scope_enum() {
930        // Test Controller scope
931        let controller_scope = TagScope::Controller;
932        assert_eq!(controller_scope, TagScope::Controller);
933
934        // Test Program scope
935        let program_scope = TagScope::Program("MainProgram".to_string());
936        match program_scope {
937            TagScope::Program(name) => assert_eq!(name, "MainProgram"),
938            _ => panic!("Expected Program scope"),
939        }
940
941        // Test Global scope
942        let global_scope = TagScope::Global;
943        assert_eq!(global_scope, TagScope::Global);
944
945        // Test Local scope
946        let local_scope = TagScope::Local;
947        assert_eq!(local_scope, TagScope::Local);
948    }
949
950    #[test]
951    fn test_array_info() {
952        let array_info = ArrayInfo {
953            dimensions: vec![10, 20],
954            element_count: 200,
955        };
956
957        assert_eq!(array_info.dimensions, vec![10, 20]);
958        assert_eq!(array_info.element_count, 200);
959    }
960
961    #[test]
962    fn test_tag_permissions() {
963        let permissions = TagPermissions {
964            readable: true,
965            writable: false,
966        };
967
968        assert!(permissions.readable);
969        assert!(!permissions.writable);
970    }
971}