Skip to main content

plc_comm_hostlink/
address.rs

1use crate::error::HostLinkError;
2use std::fmt;
3
4const DEVICE_TYPES_PARSE_ORDER: &[&str] = &[
5    "MR", "LR", "CR", "VB", "DM", "EM", "FM", "ZF", "TM", "TC", "TS", "CC", "CS", "AT", "CM", "VM",
6    "R", "B", "W", "Z", "T", "C", "X", "Y", "M", "L", "D", "E", "F",
7];
8const FORCE_DEVICE_TYPES: &[&str] = &["R", "B", "MR", "LR", "CR", "T", "C", "VB"];
9const MBS_DEVICE_TYPES: &[&str] = &[
10    "R", "B", "MR", "LR", "CR", "T", "C", "VB", "X", "Y", "M", "L",
11];
12const MWS_DEVICE_TYPES: &[&str] = &[
13    "R", "B", "MR", "LR", "CR", "VB", "X", "Y", "DM", "EM", "FM", "W", "TM", "Z", "TC", "TS", "CC",
14    "CS", "CM", "VM",
15];
16const RDC_DEVICE_TYPES: &[&str] = &[
17    "R", "B", "MR", "LR", "CR", "DM", "EM", "FM", "ZF", "W", "TM", "Z", "T", "C", "CM", "X", "Y",
18    "M", "L", "D", "E", "F",
19];
20const WR_DEVICE_TYPES: &[&str] = &[
21    "R", "B", "MR", "LR", "CR", "VB", "DM", "EM", "FM", "ZF", "W", "TM", "Z", "T", "TC", "TS", "C",
22    "CC", "CS", "CM", "VM", "X", "Y", "M", "L", "D", "E", "F",
23];
24const WS_DEVICE_TYPES: &[&str] = &["T", "C"];
25
26#[derive(Debug, Clone, Copy)]
27struct DeviceRange {
28    lo: u32,
29    hi: u32,
30    base: u32,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct KvDeviceAddress {
35    pub device_type: String,
36    pub number: u32,
37    pub suffix: String,
38}
39
40impl KvDeviceAddress {
41    pub fn to_text(&self) -> Result<String, HostLinkError> {
42        let range = device_range(&self.device_type).ok_or_else(|| {
43            HostLinkError::protocol(format!("Unsupported device type: {}", self.device_type))
44        })?;
45        let number = if uses_bit_bank_address(&self.device_type) {
46            format_bit_bank_number(self.number)
47        } else if uses_xym_bit_address(&self.device_type) {
48            format_xym_bit_number(self.number)
49        } else if range.base == 16 {
50            format!("{:X}", self.number)
51        } else {
52            self.number.to_string()
53        };
54        Ok(format!("{}{}{}", self.device_type, number, self.suffix))
55    }
56}
57
58impl fmt::Display for KvDeviceAddress {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        match self.to_text() {
61            Ok(text) => write!(f, "{text}"),
62            Err(_) => write!(f, "{}{}{}", self.device_type, self.number, self.suffix),
63        }
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct KvLogicalAddress {
69    pub base_address: KvDeviceAddress,
70    pub data_type: String,
71    pub bit_index: Option<u8>,
72}
73
74impl KvLogicalAddress {
75    pub fn is_bit_in_word(&self) -> bool {
76        self.bit_index.is_some()
77    }
78
79    pub fn to_text(&self) -> Result<String, HostLinkError> {
80        let mut base = self.base_address.clone();
81        base.suffix.clear();
82        let base_text = base.to_text()?;
83        if let Some(bit_index) = self.bit_index {
84            return Ok(format!("{base_text}.{bit_index:X}"));
85        }
86
87        if self.data_type == logical_default_dtype_by_device_type(&self.base_address.device_type) {
88            Ok(base_text)
89        } else {
90            Ok(format!("{base_text}:{}", self.data_type))
91        }
92    }
93}
94
95pub struct HostLinkAddress;
96
97impl HostLinkAddress {
98    pub fn parse(text: &str) -> Result<KvDeviceAddress, HostLinkError> {
99        parse_device(text)
100    }
101
102    pub fn try_parse(text: &str) -> Option<KvDeviceAddress> {
103        parse_device(text).ok()
104    }
105
106    pub fn format(address: &KvDeviceAddress) -> Result<String, HostLinkError> {
107        address.to_text()
108    }
109
110    pub fn normalize(text: &str) -> Result<String, HostLinkError> {
111        if let Ok(address) = parse_device(text) {
112            return address.to_text();
113        }
114
115        parse_logical_address(text)?.to_text()
116    }
117
118    pub fn parse_logical(text: &str) -> Result<KvLogicalAddress, HostLinkError> {
119        parse_logical_address(text)
120    }
121
122    pub fn try_parse_logical(text: &str) -> Option<KvLogicalAddress> {
123        parse_logical_address(text).ok()
124    }
125
126    pub fn normalize_logical(text: &str) -> Result<String, HostLinkError> {
127        parse_logical_address(text)?.to_text()
128    }
129}
130
131pub(crate) fn model_name_for_code(code: &str) -> &str {
132    match code {
133        "134" => "KV-N24nn",
134        "133" => "KV-N40nn",
135        "132" => "KV-N60nn",
136        "128" => "KV-NC32T",
137        "63" => "KV-X550",
138        "61" => "KV-X530",
139        "60" => "KV-X520",
140        "62" => "KV-X500",
141        "59" => "KV-X310",
142        "58" => "KV-8000A",
143        "57" => "KV-8000",
144        "55" => "KV-7500",
145        "54" => "KV-7300",
146        "53" => "KV-5500",
147        "52" => "KV-5000",
148        "51" => "KV-3000",
149        "50" => "KV-1000",
150        "49" => "KV-700 (With expansion memory)",
151        "48" => "KV-700 (No expansion memory)",
152        _ => "Unknown",
153    }
154}
155
156pub(crate) fn force_device_types() -> &'static [&'static str] {
157    FORCE_DEVICE_TYPES
158}
159
160pub(crate) fn mbs_device_types() -> &'static [&'static str] {
161    MBS_DEVICE_TYPES
162}
163
164pub(crate) fn mws_device_types() -> &'static [&'static str] {
165    MWS_DEVICE_TYPES
166}
167
168pub(crate) fn rdc_device_types() -> &'static [&'static str] {
169    RDC_DEVICE_TYPES
170}
171
172pub(crate) fn wr_device_types() -> &'static [&'static str] {
173    WR_DEVICE_TYPES
174}
175
176pub(crate) fn ws_device_types() -> &'static [&'static str] {
177    WS_DEVICE_TYPES
178}
179
180pub(crate) fn default_format_by_device_type(device_type: &str) -> &'static str {
181    match device_type {
182        "R" | "B" | "MR" | "LR" | "CR" | "VB" | "X" | "Y" | "M" | "L" => "",
183        "DM" | "EM" | "FM" | "ZF" | "W" | "TM" | "Z" | "CM" | "VM" | "D" | "E" | "F" => ".U",
184        "AT" => ".D",
185        "T" | "TC" | "TS" | "C" | "CC" | "CS" => ".D",
186        _ => "",
187    }
188}
189
190pub(crate) fn logical_default_dtype_by_device_type(device_type: &str) -> &'static str {
191    default_format_by_device_type(device_type).trim_start_matches('.')
192}
193
194pub(crate) fn is_direct_bit_device_type(device_type: &str) -> bool {
195    matches!(
196        device_type,
197        "R" | "B" | "MR" | "LR" | "CR" | "VB" | "X" | "Y" | "M" | "L"
198    )
199}
200
201pub(crate) fn uses_bit_bank_address(device_type: &str) -> bool {
202    matches!(device_type, "R" | "MR" | "LR" | "CR")
203}
204
205fn uses_xym_bit_address(device_type: &str) -> bool {
206    matches!(device_type, "X" | "Y")
207}
208
209fn is_valid_bit_bank_number(number: u32) -> bool {
210    number % 100 <= 15
211}
212
213pub(crate) fn bit_bank_logical_number(number: u32) -> u32 {
214    (number / 100) * 16 + (number % 100)
215}
216
217fn bit_bank_number_from_logical(number: u32) -> u32 {
218    (number / 16) * 100 + (number % 16)
219}
220
221fn format_bit_bank_number(number: u32) -> String {
222    let bank = number / 100;
223    let bit = number % 100;
224    format!("{bank}{bit:02}")
225}
226
227fn format_xym_bit_number(number: u32) -> String {
228    let bank = number / 16;
229    let bit = number % 16;
230    format!("{bank}{bit:X}")
231}
232
233pub(crate) fn is_optimizable_read_named_device_type(device_type: &str) -> bool {
234    default_format_by_device_type(device_type) == ".U"
235}
236
237pub(crate) fn offset_device(
238    start: &KvDeviceAddress,
239    word_offset: u32,
240) -> Result<String, HostLinkError> {
241    let mut next = start.clone();
242    next.number = if uses_bit_bank_address(&next.device_type) {
243        let logical = bit_bank_logical_number(next.number)
244            .checked_add(word_offset)
245            .ok_or_else(|| HostLinkError::protocol("Device offset overflow"))?;
246        bit_bank_number_from_logical(logical)
247    } else {
248        next.number
249            .checked_add(word_offset)
250            .ok_or_else(|| HostLinkError::protocol("Device offset overflow"))?
251    };
252    next.suffix.clear();
253    next.to_text()
254}
255
256pub(crate) fn parse_named_address_parts(
257    address: &str,
258) -> Result<(String, String, Option<u8>), HostLinkError> {
259    let logical = parse_logical_address(address)?;
260    let mut base = logical.base_address;
261    base.suffix.clear();
262    Ok((base.to_text()?, logical.data_type, logical.bit_index))
263}
264
265pub fn normalize_suffix(suffix: impl AsRef<str>) -> Result<String, HostLinkError> {
266    let suffix = suffix.as_ref();
267    if suffix.is_empty() {
268        return Ok(String::new());
269    }
270
271    let mut normalized = suffix.trim().to_ascii_uppercase();
272    if !normalized.starts_with('.') {
273        normalized.insert(0, '.');
274    }
275
276    match normalized.as_str() {
277        ".U" | ".S" | ".D" | ".L" | ".H" => Ok(normalized),
278        _ => Err(HostLinkError::protocol(format!(
279            "Unsupported data format suffix: {suffix}"
280        ))),
281    }
282}
283
284pub fn parse_device(text: &str) -> Result<KvDeviceAddress, HostLinkError> {
285    parse_device_internal(text, true)
286}
287
288fn parse_device_internal(
289    text: &str,
290    allow_omitted_type: bool,
291) -> Result<KvDeviceAddress, HostLinkError> {
292    let raw = text.trim().to_ascii_uppercase();
293    if raw.is_empty() {
294        return Err(HostLinkError::protocol("Device string must not be empty"));
295    }
296
297    let (core, suffix) = extract_suffix(&raw)?;
298    let (device_type, number_text) = if let Some(device_type) = DEVICE_TYPES_PARSE_ORDER
299        .iter()
300        .find(|candidate| core.starts_with(**candidate))
301    {
302        (
303            (*device_type).to_owned(),
304            core[device_type.len()..].to_owned(),
305        )
306    } else if allow_omitted_type && core.bytes().all(|byte| byte.is_ascii_digit()) {
307        ("R".to_owned(), core.to_owned())
308    } else {
309        return Err(HostLinkError::protocol(format!(
310            "Invalid device string '{text}'. Valid device types: {}.",
311            DEVICE_TYPES_PARSE_ORDER.join(", ")
312        )));
313    };
314
315    if number_text.is_empty() || !number_text.bytes().all(|byte| byte.is_ascii_hexdigit()) {
316        return Err(HostLinkError::protocol(format!(
317            "Invalid device number for {device_type}: {number_text}"
318        )));
319    }
320
321    let range = device_range(&device_type).ok_or_else(|| {
322        HostLinkError::protocol(format!("Unsupported device type: {device_type}"))
323    })?;
324
325    let number = if uses_xym_bit_address(&device_type) {
326        parse_xym_bit_number(&device_type, &number_text)?
327    } else {
328        u32::from_str_radix(&number_text, range.base).map_err(|_| {
329            HostLinkError::protocol(format!(
330                "Invalid device number for {device_type}: {number_text}"
331            ))
332        })?
333    };
334    if number < range.lo || number > range.hi {
335        return Err(HostLinkError::protocol(format!(
336            "Device number out of range: {device_type}{number_text} (allowed: {}..{})",
337            format_device_number(&device_type, range.lo),
338            format_device_number(&device_type, range.hi)
339        )));
340    }
341    if uses_bit_bank_address(&device_type) && !is_valid_bit_bank_number(number) {
342        return Err(HostLinkError::protocol(format!(
343            "Invalid bit-bank device number: {device_type}{number_text} (lower two digits must be 00..15)"
344        )));
345    }
346
347    Ok(KvDeviceAddress {
348        device_type,
349        number,
350        suffix,
351    })
352}
353
354pub fn parse_logical_address(text: &str) -> Result<KvLogicalAddress, HostLinkError> {
355    let raw = text.trim();
356    if raw.is_empty() {
357        return Err(HostLinkError::protocol("Address must not be empty"));
358    }
359
360    if let Some(colon_index) = raw.find(':') {
361        let base = parse_device(&raw[..colon_index])?;
362        let mut base = base;
363        base.suffix.clear();
364        return Ok(KvLogicalAddress {
365            base_address: base,
366            data_type: normalize_dtype(&raw[colon_index + 1..])?,
367            bit_index: None,
368        });
369    }
370
371    if let Some(dot_index) = raw.rfind('.') {
372        if let Ok(bit_index) = u8::from_str_radix(&raw[dot_index + 1..], 16) {
373            if bit_index <= 15 {
374                let mut base = parse_device(&raw[..dot_index])?;
375                base.suffix.clear();
376                return Ok(KvLogicalAddress {
377                    base_address: base,
378                    data_type: "BIT_IN_WORD".to_owned(),
379                    bit_index: Some(bit_index),
380                });
381            }
382        }
383    }
384
385    let mut base = parse_device(raw)?;
386    let data_type = if base.suffix.is_empty() {
387        logical_default_dtype_by_device_type(&base.device_type).to_owned()
388    } else {
389        normalize_dtype(&base.suffix)?
390    };
391    base.suffix.clear();
392    Ok(KvLogicalAddress {
393        base_address: base,
394        data_type,
395        bit_index: None,
396    })
397}
398
399pub fn resolve_effective_format(device_type: &str, suffix: &str) -> String {
400    if suffix.is_empty() {
401        default_format_by_device_type(device_type).to_owned()
402    } else {
403        suffix.to_owned()
404    }
405}
406
407pub fn validate_device_type(
408    command: &str,
409    device_type: &str,
410    allowed_types: &[&str],
411) -> Result<(), HostLinkError> {
412    if allowed_types.contains(&device_type) {
413        Ok(())
414    } else {
415        Err(HostLinkError::protocol(format!(
416            "Command '{command}' does not support device type '{device_type}'. Supported types: {}.",
417            allowed_types.join(", ")
418        )))
419    }
420}
421
422pub fn validate_device_count(
423    device_type: &str,
424    effective_format: &str,
425    count: usize,
426) -> Result<(), HostLinkError> {
427    let is_32_bit = matches!(effective_format, ".D" | ".L");
428    let (lo, hi) = match device_type {
429        "TM" => (1, if is_32_bit { 256 } else { 512 }),
430        "Z" => (1, 12),
431        "AT" => (1, 8),
432        "T" | "TC" | "TS" | "C" | "CC" | "CS" => (1, 120),
433        _ => (1, if is_32_bit { 500 } else { 1000 }),
434    };
435
436    if !(lo..=hi).contains(&count) {
437        return Err(HostLinkError::protocol(format!(
438            "Count {count} is out of range for device type '{device_type}' with format '{effective_format}' (allowed: {lo}..{hi})."
439        )));
440    }
441
442    Ok(())
443}
444
445pub fn validate_device_span(
446    device_type: &str,
447    start_number: u32,
448    effective_format: &str,
449    count: usize,
450) -> Result<(), HostLinkError> {
451    let range = device_range(device_type).ok_or_else(|| {
452        HostLinkError::protocol(format!("Unsupported device type: {device_type}"))
453    })?;
454    if count == 0 {
455        return Err(HostLinkError::protocol(
456            "count out of range: 0 (allowed: 1..)",
457        ));
458    }
459
460    let word_width = if device_type == "AT" {
461        1u32
462    } else if matches!(effective_format, ".D" | ".L") {
463        2u32
464    } else {
465        1u32
466    };
467    let start_span_number = if uses_bit_bank_address(device_type) {
468        bit_bank_logical_number(start_number)
469    } else {
470        start_number
471    };
472    let hi_span_number = if uses_bit_bank_address(device_type) {
473        bit_bank_logical_number(range.hi)
474    } else {
475        range.hi
476    };
477    let end_span_number = start_span_number
478        .checked_add((count as u32).saturating_mul(word_width))
479        .and_then(|value| value.checked_sub(1))
480        .ok_or_else(|| HostLinkError::protocol("Device span overflow"))?;
481
482    if start_number < range.lo || start_number > range.hi || end_span_number > hi_span_number {
483        let start_text = format_device_number(device_type, start_number);
484        let end_number = if uses_bit_bank_address(device_type) {
485            bit_bank_number_from_logical(end_span_number)
486        } else {
487            end_span_number
488        };
489        let end_text = format_device_number(device_type, end_number);
490        return Err(HostLinkError::protocol(format!(
491            "Device span out of range: {device_type}{start_text}..{device_type}{end_text} with format '{effective_format}'"
492        )));
493    }
494
495    Ok(())
496}
497
498pub fn validate_expansion_buffer_count(
499    effective_format: &str,
500    count: usize,
501) -> Result<(), HostLinkError> {
502    let hi = if matches!(effective_format, ".D" | ".L") {
503        500
504    } else {
505        1000
506    };
507    if !(1..=hi).contains(&count) {
508        return Err(HostLinkError::protocol(format!(
509            "Count {count} is out of range for expansion buffer format '{effective_format}' (allowed: 1..{hi})."
510        )));
511    }
512    Ok(())
513}
514
515pub fn validate_expansion_buffer_span(
516    address: u32,
517    effective_format: &str,
518    count: usize,
519) -> Result<(), HostLinkError> {
520    if count == 0 {
521        return Err(HostLinkError::protocol(
522            "count out of range: 0 (allowed: 1..)",
523        ));
524    }
525
526    let word_width = if matches!(effective_format, ".D" | ".L") {
527        2u32
528    } else {
529        1u32
530    };
531    let end_address = address
532        .checked_add((count as u32).saturating_mul(word_width))
533        .and_then(|value| value.checked_sub(1))
534        .ok_or_else(|| HostLinkError::protocol("Expansion buffer span overflow"))?;
535    if address > 59_999 || end_address > 59_999 {
536        return Err(HostLinkError::protocol(format!(
537            "Expansion buffer span out of range: {address}..{end_address} with format '{effective_format}'"
538        )));
539    }
540    Ok(())
541}
542
543fn normalize_dtype(text: &str) -> Result<String, HostLinkError> {
544    match text
545        .trim()
546        .trim_start_matches('.')
547        .to_ascii_uppercase()
548        .as_str()
549    {
550        "U" => Ok("U".to_owned()),
551        "S" => Ok("S".to_owned()),
552        "D" => Ok("D".to_owned()),
553        "L" => Ok("L".to_owned()),
554        "F" => Ok("F".to_owned()),
555        "COMMENT" => Ok("COMMENT".to_owned()),
556        _ => Err(HostLinkError::protocol(format!(
557            "Unsupported logical data type '{text}'."
558        ))),
559    }
560}
561
562fn extract_suffix(raw: &str) -> Result<(&str, String), HostLinkError> {
563    if raw.len() >= 2 && raw.as_bytes()[raw.len() - 2] == b'.' {
564        let suffix = normalize_suffix(&raw[raw.len() - 2..])?;
565        Ok((&raw[..raw.len() - 2], suffix))
566    } else {
567        Ok((raw, String::new()))
568    }
569}
570
571fn format_device_number(device_type: &str, value: u32) -> String {
572    if uses_bit_bank_address(device_type) {
573        return format_bit_bank_number(value);
574    }
575    if uses_xym_bit_address(device_type) {
576        return format_xym_bit_number(value);
577    }
578
579    let Some(range) = device_range(device_type) else {
580        return value.to_string();
581    };
582    if range.base == 16 {
583        format!("{value:X}")
584    } else {
585        value.to_string()
586    }
587}
588
589fn parse_xym_bit_number(device_type: &str, number_text: &str) -> Result<u32, HostLinkError> {
590    let bank_text = if number_text.len() == 1 {
591        "0"
592    } else {
593        &number_text[..number_text.len() - 1]
594    };
595    if !bank_text.bytes().all(|byte| byte.is_ascii_digit()) {
596        return Err(HostLinkError::protocol(format!(
597            "Invalid X/Y device number: {device_type}{number_text} (bank digits must be decimal and bit digit must be 0..F)"
598        )));
599    }
600
601    let bank = bank_text.parse::<u32>().map_err(|_| {
602        HostLinkError::protocol(format!(
603            "Invalid device number for {device_type}: {number_text}"
604        ))
605    })?;
606    let bit = u32::from_str_radix(&number_text[number_text.len() - 1..], 16).map_err(|_| {
607        HostLinkError::protocol(format!(
608            "Invalid device number for {device_type}: {number_text}"
609        ))
610    })?;
611    bank.checked_mul(16)
612        .and_then(|value| value.checked_add(bit))
613        .ok_or_else(|| {
614            HostLinkError::protocol(format!(
615                "Invalid device number for {device_type}: {number_text}"
616            ))
617        })
618}
619
620fn device_range(device_type: &str) -> Option<DeviceRange> {
621    let range = match device_type {
622        "R" => DeviceRange {
623            lo: 0,
624            hi: 199_915,
625            base: 10,
626        },
627        "B" => DeviceRange {
628            lo: 0,
629            hi: 0x7FFF,
630            base: 16,
631        },
632        "MR" => DeviceRange {
633            lo: 0,
634            hi: 399_915,
635            base: 10,
636        },
637        "LR" => DeviceRange {
638            lo: 0,
639            hi: 99_915,
640            base: 10,
641        },
642        "CR" => DeviceRange {
643            lo: 0,
644            hi: 7_915,
645            base: 10,
646        },
647        "VB" => DeviceRange {
648            lo: 0,
649            hi: 0xF9FF,
650            base: 16,
651        },
652        "DM" => DeviceRange {
653            lo: 0,
654            hi: 65_534,
655            base: 10,
656        },
657        "EM" => DeviceRange {
658            lo: 0,
659            hi: 65_534,
660            base: 10,
661        },
662        "FM" => DeviceRange {
663            lo: 0,
664            hi: 32_767,
665            base: 10,
666        },
667        "ZF" => DeviceRange {
668            lo: 0,
669            hi: 524_287,
670            base: 10,
671        },
672        "W" => DeviceRange {
673            lo: 0,
674            hi: 0x7FFF,
675            base: 16,
676        },
677        "TM" => DeviceRange {
678            lo: 0,
679            hi: 511,
680            base: 10,
681        },
682        "Z" => DeviceRange {
683            lo: 1,
684            hi: 12,
685            base: 10,
686        },
687        "T" | "TC" | "TS" | "C" | "CC" | "CS" => DeviceRange {
688            lo: 0,
689            hi: 3_999,
690            base: 10,
691        },
692        "AT" => DeviceRange {
693            lo: 0,
694            hi: 7,
695            base: 10,
696        },
697        "CM" => DeviceRange {
698            lo: 0,
699            hi: 7_599,
700            base: 10,
701        },
702        "VM" => DeviceRange {
703            lo: 0,
704            hi: 589_823,
705            base: 10,
706        },
707        "X" => DeviceRange {
708            lo: 0,
709            hi: 1_999 * 16 + 15,
710            base: 10,
711        },
712        "Y" => DeviceRange {
713            lo: 0,
714            hi: 1_999 * 16 + 15,
715            base: 10,
716        },
717        "M" => DeviceRange {
718            lo: 0,
719            hi: 63_999,
720            base: 10,
721        },
722        "L" => DeviceRange {
723            lo: 0,
724            hi: 15_999,
725            base: 10,
726        },
727        "D" | "E" => DeviceRange {
728            lo: 0,
729            hi: 65_534,
730            base: 10,
731        },
732        "F" => DeviceRange {
733            lo: 0,
734            hi: 32_767,
735            base: 10,
736        },
737        _ => return None,
738    };
739    Some(range)
740}
741
742#[cfg(test)]
743mod tests {
744    use super::{
745        HostLinkAddress, offset_device, parse_device, parse_logical_address, validate_device_span,
746        wr_device_types,
747    };
748
749    #[test]
750    fn parse_device_normalizes_hex_suffix_and_number() {
751        let address = parse_device("w1a.h").unwrap();
752        assert_eq!(address.device_type, "W");
753        assert_eq!(address.number, 0x1A);
754        assert_eq!(address.suffix, ".H");
755        assert_eq!(address.to_text().unwrap(), "W1A.H");
756    }
757
758    #[test]
759    fn parse_logical_bit_index_uses_hex_notation() {
760        let logical = parse_logical_address("dm100.a").unwrap();
761        assert_eq!(logical.to_text().unwrap(), "DM100.A");
762        assert_eq!(logical.bit_index, Some(10));
763    }
764
765    #[test]
766    fn normalize_plain_address_keeps_default_r_omission_rule() {
767        assert_eq!(HostLinkAddress::normalize("100").unwrap(), "R100");
768    }
769
770    #[test]
771    fn parse_logical_comment_address_round_trips() {
772        let logical = parse_logical_address("dm100:comment").unwrap();
773        assert_eq!(logical.to_text().unwrap(), "DM100:COMMENT");
774        assert_eq!(logical.data_type, "COMMENT");
775    }
776
777    #[test]
778    fn parse_logical_direct_bit_defaults_to_bool_read() {
779        let logical = parse_logical_address("cr0").unwrap();
780        assert_eq!(logical.to_text().unwrap(), "CR000");
781        assert_eq!(logical.data_type, "");
782    }
783
784    #[test]
785    fn parse_device_rejects_invalid_bit_bank_numbers() {
786        assert!(parse_device("R016").is_err());
787        assert!(parse_device("MR116").is_err());
788        assert!(parse_device("LR99916").is_err());
789        assert!(parse_device("CR7916").is_err());
790    }
791
792    #[test]
793    fn parse_device_accepts_valid_bit_bank_boundaries() {
794        assert_eq!(parse_device("R0").unwrap().to_text().unwrap(), "R000");
795        assert_eq!(parse_device("R1").unwrap().to_text().unwrap(), "R001");
796        assert_eq!(parse_device("R015").unwrap().to_text().unwrap(), "R015");
797        assert_eq!(parse_device("R100").unwrap().to_text().unwrap(), "R100");
798        assert_eq!(parse_device("MR115").unwrap().to_text().unwrap(), "MR115");
799        assert_eq!(parse_device("CR0").unwrap().to_text().unwrap(), "CR000");
800        assert_eq!(parse_device("CR7915").unwrap().to_text().unwrap(), "CR7915");
801    }
802
803    #[test]
804    fn bit_bank_offsets_cross_bank_boundaries_by_bit_position() {
805        let start = parse_device("CR3614").unwrap();
806        assert_eq!(offset_device(&start, 0).unwrap(), "CR3614");
807        assert_eq!(offset_device(&start, 1).unwrap(), "CR3615");
808        assert_eq!(offset_device(&start, 2).unwrap(), "CR3700");
809        assert_eq!(offset_device(&start, 18).unwrap(), "CR3800");
810    }
811
812    #[test]
813    fn validate_device_span_uses_bit_bank_point_count() {
814        validate_device_span("CR", 7900, "", 16).unwrap();
815        assert!(validate_device_span("CR", 7900, "", 17).is_err());
816    }
817
818    #[test]
819    fn validate_device_span_treats_at_32bit_as_device_points() {
820        validate_device_span("AT", 7, ".D", 1).unwrap();
821        validate_device_span("AT", 0, ".D", 8).unwrap();
822        assert!(validate_device_span("AT", 1, ".D", 8).is_err());
823    }
824
825    #[test]
826    fn parse_device_accepts_high_xym_m_addresses() {
827        assert_eq!(parse_device("M63872").unwrap().to_text().unwrap(), "M63872");
828        assert!(parse_device("M64000").is_err());
829    }
830
831    #[test]
832    fn parse_device_uses_decimal_bank_hex_bit_for_xym_bits() {
833        let address = parse_device("X390").unwrap();
834        assert_eq!(address.device_type, "X");
835        assert_eq!(address.number, 39 * 16);
836        assert_eq!(address.to_text().unwrap(), "X390");
837
838        assert_eq!(
839            parse_device("X3F0").unwrap_err().to_string(),
840            "Invalid X/Y device number: X3F0 (bank digits must be decimal and bit digit must be 0..F)"
841        );
842        assert_eq!(parse_device("X1999F").unwrap().to_text().unwrap(), "X1999F");
843        assert!(parse_device("X20000").is_err());
844        assert_eq!(parse_device("Y1999F").unwrap().to_text().unwrap(), "Y1999F");
845        assert!(parse_device("Y20000").is_err());
846    }
847
848    #[test]
849    fn validate_device_span_allows_xym_m_upper_bound() {
850        validate_device_span("X", 1_999 * 16 + 15, "", 1).unwrap();
851        assert!(validate_device_span("X", 1_999 * 16 + 15, "", 2).is_err());
852        validate_device_span("Y", 1_999 * 16 + 15, "", 1).unwrap();
853        assert!(validate_device_span("Y", 1_999 * 16 + 15, "", 2).is_err());
854        validate_device_span("M", 63_998, "", 1).unwrap();
855        validate_device_span("M", 63_998, "", 2).unwrap();
856        assert!(validate_device_span("M", 63_999, "", 2).is_err());
857        assert!(validate_device_span("L", 16_000, "", 1).is_err());
858    }
859
860    #[test]
861    fn parse_logical_suffix_preserves_explicit_type() {
862        let logical = parse_logical_address("dm100.s").unwrap();
863        assert_eq!(logical.to_text().unwrap(), "DM100:S");
864        assert_eq!(logical.data_type, "S");
865    }
866
867    #[test]
868    fn parse_logical_counter_defaults_to_dword_read() {
869        let logical = parse_logical_address("t0").unwrap();
870        assert_eq!(logical.to_text().unwrap(), "T0");
871        assert_eq!(logical.data_type, "D");
872    }
873
874    #[test]
875    fn parse_logical_at_defaults_to_dword_read() {
876        let logical = parse_logical_address("at7").unwrap();
877        assert_eq!(logical.to_text().unwrap(), "AT7");
878        assert_eq!(logical.data_type, "D");
879    }
880
881    #[test]
882    fn wr_device_types_exclude_at() {
883        assert!(!wr_device_types().contains(&"AT"));
884        assert!(wr_device_types().contains(&"DM"));
885        assert!(wr_device_types().contains(&"TS"));
886    }
887}