kenwood_thd75/sdcard/
callsign_list.rs

1//! Parser for D-STAR callsign list `.tsv` files.
2//!
3//! The callsign list is a UTF-16LE encoded, tab-separated file with a
4//! BOM (`FF FE`). It stores D-STAR destination callsigns (URCALL
5//! addresses) used for direct calling.
6//!
7//! # Location
8//!
9//! `/KENWOOD/TH-D75/SETTINGS/CALLSIGN_LIST/*.tsv`
10//!
11//! # Capacity
12//!
13//! Up to 120 entries.
14
15use super::SdCardError;
16
17/// A single D-STAR destination callsign entry.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct CallsignEntry {
20    /// D-STAR destination callsign (URCALL), 8 chars, space-padded.
21    pub callsign: String,
22}
23
24/// Parses a callsign list TSV file from raw bytes.
25///
26/// Expects UTF-16LE encoding with a BOM prefix (`FF FE`). The first
27/// line is treated as a column header and is skipped. Each subsequent
28/// line contains at least one column (the callsign).
29///
30/// # Errors
31///
32/// Returns an [`SdCardError`] if the encoding is invalid or a data
33/// row has no columns.
34pub fn parse_callsign_list(data: &[u8]) -> Result<Vec<CallsignEntry>, SdCardError> {
35    let text = decode_utf16le_bom(data)?;
36    let mut entries = Vec::new();
37
38    for (line_idx, line) in text.lines().enumerate() {
39        // Skip header row and blank lines.
40        if line_idx == 0 || line.trim().is_empty() {
41            continue;
42        }
43
44        let line_num = line_idx + 1;
45        let cols: Vec<&str> = line.split('\t').collect();
46        if cols.is_empty() {
47            return Err(SdCardError::ColumnCount {
48                line: line_num,
49                expected: 1,
50                actual: 0,
51            });
52        }
53
54        let callsign = cols[0].to_owned();
55
56        // Skip the D-STAR broadcast CQ address — it is always implicit.
57        if callsign.trim() == "CQCQCQ" {
58            continue;
59        }
60
61        entries.push(CallsignEntry { callsign });
62    }
63
64    Ok(entries)
65}
66
67/// Generates a callsign list TSV file as UTF-16LE bytes with BOM.
68///
69/// The output includes a header row followed by one row per entry.
70#[must_use]
71pub fn write_callsign_list(entries: &[CallsignEntry]) -> Vec<u8> {
72    let mut text = String::new();
73
74    // Header row
75    text.push_str("Callsign\r\n");
76
77    // Data rows
78    for entry in entries {
79        text.push_str(&entry.callsign);
80        text.push_str("\r\n");
81    }
82
83    encode_utf16le_bom(&text)
84}
85
86/// Decodes a UTF-16LE byte sequence with a leading BOM into a `String`.
87fn decode_utf16le_bom(data: &[u8]) -> Result<String, SdCardError> {
88    if data.len() < 2 {
89        return Err(SdCardError::MissingBom);
90    }
91    if data[0] != 0xFF || data[1] != 0xFE {
92        return Err(SdCardError::MissingBom);
93    }
94
95    let payload = &data[2..];
96    if !payload.len().is_multiple_of(2) {
97        return Err(SdCardError::InvalidUtf16Length { len: payload.len() });
98    }
99
100    let code_units: Vec<u16> = payload
101        .chunks_exact(2)
102        .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
103        .collect();
104
105    String::from_utf16(&code_units).map_err(|e| SdCardError::Utf16Decode {
106        detail: e.to_string(),
107    })
108}
109
110/// Encodes a string as UTF-16LE bytes with a leading BOM.
111fn encode_utf16le_bom(text: &str) -> Vec<u8> {
112    let mut out = Vec::with_capacity(2 + text.len() * 2);
113    // BOM
114    out.push(0xFF);
115    out.push(0xFE);
116    // UTF-16LE payload
117    for unit in text.encode_utf16() {
118        let bytes = unit.to_le_bytes();
119        out.push(bytes[0]);
120        out.push(bytes[1]);
121    }
122    out
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn parse_empty_list() {
131        let data = encode_utf16le_bom("Callsign\r\n");
132        let entries = parse_callsign_list(&data).unwrap();
133        assert!(entries.is_empty());
134    }
135
136    #[test]
137    fn parse_filters_cqcqcq() {
138        let data = encode_utf16le_bom("Callsign\r\nCQCQCQ  \r\nW4CDR   \r\n");
139        let entries = parse_callsign_list(&data).unwrap();
140        // CQCQCQ (with trailing spaces trimmed) should be filtered out.
141        assert_eq!(entries.len(), 1);
142        assert_eq!(entries[0].callsign, "W4CDR   ");
143    }
144}