Skip to main content

aivdm/
string.rs

1//! Six-bit ASCII conversions and a fixed-capacity string type for decoded
2//! text fields (vessel name, callsign, destination).
3//!
4//! Two distinct six-bit alphabets are involved:
5//! - the **NMEA armor** alphabet, used to pack six-bit payload symbols into
6//!   printable ASCII for the `!AIVDM`/`!AIVDO` payload field
7//!   ([`armor_char_to_sixbit`]/[`sixbit_to_armor_char`]);
8//! - the **ITU-R M.1371 Annex 8 six-bit ASCII** text alphabet, used *inside*
9//!   the payload for string fields ([`sixbit_to_ascii`]/[`ascii_to_sixbit`]).
10
11/// Converts one NMEA armor character back to its six-bit payload value.
12///
13/// Callers are expected to have validated the byte with
14/// [`is_valid_armor_byte`] beforehand; invalid input does not panic but
15/// produces an unspecified six-bit value.
16#[must_use]
17pub const fn armor_char_to_sixbit(c: u8) -> u8 {
18    let v = c.wrapping_sub(48);
19    if v > 40 { v.wrapping_sub(8) } else { v }
20}
21
22/// Converts a six-bit payload value (`0..=63`) to its NMEA armor character.
23#[must_use]
24pub const fn sixbit_to_armor_char(v: u8) -> u8 {
25    let c = v + 48;
26    if c > 87 { c + 8 } else { c }
27}
28
29/// Returns whether `c` is a valid NMEA armor character.
30#[must_use]
31pub const fn is_valid_armor_byte(c: u8) -> bool {
32    matches!(c, 48..=87 | 96..=119)
33}
34
35/// Converts an ITU-R M.1371 six-bit ASCII text value (`0..=63`) to its ASCII byte.
36#[must_use]
37pub const fn sixbit_to_ascii(v: u8) -> u8 {
38    if v < 32 { v + 64 } else { v }
39}
40
41/// Converts an ASCII byte to its ITU-R M.1371 six-bit ASCII text value.
42///
43/// Bytes outside the representable set (`@`-`_` and space-`?`) map to space,
44/// since these fields are defined only over that alphabet.
45#[must_use]
46pub const fn ascii_to_sixbit(c: u8) -> u8 {
47    match c {
48        b'@'..=b'_' => c - 64,
49        b' '..=b'?' => c,
50        _ => 32,
51    }
52}
53
54/// A fixed-capacity, stack-allocated string used for decoded text fields.
55///
56/// Trailing `@` padding and spaces (as used by the AIS six-bit text
57/// convention) are trimmed by [`FixedStr::as_str`].
58#[derive(Debug, Clone, Copy)]
59pub struct FixedStr<const N: usize> {
60    buf: [u8; N],
61    len: usize,
62}
63
64impl<const N: usize> PartialEq for FixedStr<N> {
65    fn eq(&self, other: &Self) -> bool {
66        self.as_str() == other.as_str()
67    }
68}
69
70impl<const N: usize> Eq for FixedStr<N> {}
71
72impl<const N: usize> FixedStr<N> {
73    /// Builds a `FixedStr` from a raw buffer and the number of meaningful
74    /// leading bytes (including any padding still present in `buf`).
75    #[must_use]
76    pub const fn from_raw(buf: [u8; N], len: usize) -> Self {
77        Self { buf, len }
78    }
79
80    /// The decoded text, with trailing `@` padding and spaces trimmed.
81    ///
82    /// The six-bit ASCII text alphabet only produces bytes in `0x20..=0x5F`,
83    /// so this is always valid UTF-8.
84    #[must_use]
85    pub fn as_str(&self) -> &str {
86        let mut end = self.len;
87        while end > 0 && matches!(self.buf[end - 1], b'@' | b' ') {
88            end -= 1;
89        }
90        core::str::from_utf8(&self.buf[..end]).unwrap_or_default()
91    }
92}
93
94impl<const N: usize> core::fmt::Display for FixedStr<N> {
95    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
96        f.write_str(self.as_str())
97    }
98}
99
100/// Builds a full-width `FixedStr<N>` from `s`, right-padded with `@`.
101///
102/// Only used by message-type unit tests to build sample field values without
103/// hand-counting padding characters in byte-string literals.
104#[cfg(test)]
105pub(crate) fn test_padded<const N: usize>(s: &str) -> FixedStr<N> {
106    let mut buf = [b'@'; N];
107    let bytes = s.as_bytes();
108    buf[..bytes.len()].copy_from_slice(bytes);
109    FixedStr::from_raw(buf, N)
110}
111
112#[cfg(kani)]
113mod kani_proofs {
114    use super::*;
115
116    // smoke test: proves armor_char_to_sixbit/sixbit_to_armor_char are exact
117    // inverses over every valid six-bit value, exhaustively rather than by
118    // sampling (see the equivalent #[test] below).
119    #[kani::proof]
120    fn armor_roundtrip() {
121        let v: u8 = kani::any();
122        kani::assume(v < 64);
123        let c = sixbit_to_armor_char(v);
124        assert!(is_valid_armor_byte(c));
125        assert_eq!(armor_char_to_sixbit(c), v);
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn armor_roundtrip_covers_full_range() {
135        for v in 0u8..64 {
136            let c = sixbit_to_armor_char(v);
137            assert!(is_valid_armor_byte(c), "char {c} for value {v} not valid");
138            assert_eq!(armor_char_to_sixbit(c), v);
139        }
140    }
141
142    #[test]
143    fn armor_known_values() {
144        assert_eq!(sixbit_to_armor_char(0), b'0');
145        assert_eq!(sixbit_to_armor_char(39), b'W');
146        assert_eq!(sixbit_to_armor_char(40), b'`');
147        assert_eq!(sixbit_to_armor_char(63), b'w');
148    }
149
150    #[test]
151    fn text_alphabet_roundtrip_covers_full_range() {
152        for v in 0u8..64 {
153            let c = sixbit_to_ascii(v);
154            assert_eq!(ascii_to_sixbit(c), v);
155        }
156    }
157
158    #[test]
159    fn fixed_str_trims_padding() {
160        let mut buf = [0u8; 8];
161        buf[..5].copy_from_slice(b"ABC@@");
162        let s: FixedStr<8> = FixedStr::from_raw(buf, 5);
163        assert_eq!(s.as_str(), "ABC");
164    }
165}