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