Skip to main content

rtcom_core/
mapper.rs

1//! Byte-stream mappers (CR/LF normalisation, future telnet/escape
2//! decoders, ...).
3//!
4//! A [`Mapper`] transforms a chunk of bytes into another chunk. It is
5//! deliberately direction-agnostic — the caller decides whether the
6//! mapper applies to inbound (`imap`), outbound (`omap`), or echoed
7//! (`emap`) traffic. v0.1 ships a single concrete mapper,
8//! [`LineEndingMapper`], that covers the picocom-equivalent
9//! `crlf`/`lfcr`/`igncr`/`ignlf` rules.
10//!
11use bytes::Bytes;
12
13/// Line-ending transformation rule.
14///
15/// Names match the picocom convention:
16///
17/// | rule          | semantics                                            |
18/// |---------------|------------------------------------------------------|
19/// | `None`        | Pass bytes through unchanged (default).              |
20/// | `AddCrToLf`   | Insert `\r` before every `\n` (LF → CRLF).           |
21/// | `AddLfToCr`   | Insert `\n` after every `\r` (CR → CRLF).            |
22/// | `DropCr`      | Discard every `\r` byte.                             |
23/// | `DropLf`      | Discard every `\n` byte.                             |
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
25pub enum LineEnding {
26    /// No transformation (default).
27    #[default]
28    None,
29    /// LF → CRLF.
30    AddCrToLf,
31    /// CR → CRLF.
32    AddLfToCr,
33    /// Drop CR.
34    DropCr,
35    /// Drop LF.
36    DropLf,
37}
38
39/// Generic byte-stream transformation.
40///
41/// `&mut self` because some future mappers (e.g. one that normalises
42/// `\r\n` straddling a chunk boundary) will keep state across calls.
43/// The line-ending mapper is stateless but pays the same signature cost.
44pub trait Mapper: Send {
45    /// Transforms the input chunk and returns the result.
46    fn map(&mut self, bytes: &[u8]) -> Bytes;
47}
48
49/// Stateless byte mapper that applies a single [`LineEnding`] rule.
50#[derive(Clone, Copy, Debug, Default)]
51pub struct LineEndingMapper {
52    rule: LineEnding,
53}
54
55impl LineEndingMapper {
56    /// Builds a mapper that applies `rule` on every call to
57    /// [`Mapper::map`].
58    #[must_use]
59    pub const fn new(rule: LineEnding) -> Self {
60        Self { rule }
61    }
62
63    /// Returns the rule this mapper was configured with.
64    #[must_use]
65    pub const fn rule(&self) -> LineEnding {
66        self.rule
67    }
68}
69
70impl Mapper for LineEndingMapper {
71    fn map(&mut self, bytes: &[u8]) -> Bytes {
72        // Fast path: identity mapping copies the slice once.
73        if matches!(self.rule, LineEnding::None) {
74            return Bytes::copy_from_slice(bytes);
75        }
76        // Worst case (Add* rules) doubles every LF/CR. Reserve a hair
77        // more than the input length to avoid the first realloc on the
78        // common case of a few line endings per chunk.
79        let mut out = Vec::with_capacity(bytes.len() + 4);
80        for &byte in bytes {
81            match (self.rule, byte) {
82                // Both Add* rules expand the matched byte to CRLF.
83                (LineEnding::AddCrToLf, b'\n') | (LineEnding::AddLfToCr, b'\r') => {
84                    out.push(b'\r');
85                    out.push(b'\n');
86                }
87                (LineEnding::DropCr, b'\r') | (LineEnding::DropLf, b'\n') => {
88                    // skip
89                }
90                _ => out.push(byte),
91            }
92        }
93        Bytes::from(out)
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    fn run(rule: LineEnding, input: &[u8]) -> Vec<u8> {
102        let mut m = LineEndingMapper::new(rule);
103        m.map(input).to_vec()
104    }
105
106    #[test]
107    fn none_passes_bytes_through_verbatim() {
108        assert_eq!(run(LineEnding::None, b""), b"");
109        assert_eq!(
110            run(LineEnding::None, b"hello\r\nworld\n"),
111            b"hello\r\nworld\n"
112        );
113    }
114
115    #[test]
116    fn default_rule_is_none() {
117        let mut m = LineEndingMapper::default();
118        assert_eq!(m.rule(), LineEnding::None);
119        assert_eq!(m.map(b"abc").to_vec(), b"abc");
120    }
121
122    #[test]
123    fn add_cr_to_lf_converts_lf_to_crlf() {
124        assert_eq!(run(LineEnding::AddCrToLf, b"hi\nyo\n"), b"hi\r\nyo\r\n");
125    }
126
127    #[test]
128    fn add_cr_to_lf_does_not_touch_existing_crlf() {
129        // The rule is "before every LF, insert CR" — so an existing CR
130        // before an LF means we get CRCRLF. That matches picocom's
131        // behaviour and keeps the rule trivially per-byte.
132        assert_eq!(run(LineEnding::AddCrToLf, b"a\r\nb"), b"a\r\r\nb");
133    }
134
135    #[test]
136    fn add_cr_to_lf_handles_consecutive_lfs() {
137        assert_eq!(run(LineEnding::AddCrToLf, b"\n\n"), b"\r\n\r\n");
138    }
139
140    #[test]
141    fn add_lf_to_cr_converts_cr_to_crlf() {
142        assert_eq!(run(LineEnding::AddLfToCr, b"hi\ryo\r"), b"hi\r\nyo\r\n");
143    }
144
145    #[test]
146    fn add_lf_to_cr_does_not_touch_existing_crlf() {
147        // Same rationale: per-byte rule, "after every CR, insert LF" — a
148        // CR already followed by LF gains a second LF.
149        assert_eq!(run(LineEnding::AddLfToCr, b"a\r\nb"), b"a\r\n\nb");
150    }
151
152    #[test]
153    fn drop_cr_removes_carriage_returns_and_keeps_other_bytes() {
154        assert_eq!(run(LineEnding::DropCr, b"a\r\nb\rc"), b"a\nbc");
155    }
156
157    #[test]
158    fn drop_lf_removes_line_feeds_and_keeps_other_bytes() {
159        assert_eq!(run(LineEnding::DropLf, b"a\r\nb\nc"), b"a\rbc");
160    }
161
162    #[test]
163    fn empty_input_yields_empty_output_for_every_rule() {
164        for rule in [
165            LineEnding::None,
166            LineEnding::AddCrToLf,
167            LineEnding::AddLfToCr,
168            LineEnding::DropCr,
169            LineEnding::DropLf,
170        ] {
171            assert!(run(rule, b"").is_empty(), "{rule:?} on empty input");
172        }
173    }
174
175    #[test]
176    fn add_cr_to_lf_leaves_non_lf_bytes_alone() {
177        // The mapper must not touch CR or arbitrary bytes when the
178        // active rule targets only LF.
179        assert_eq!(run(LineEnding::AddCrToLf, b"\rabc\x1bxyz"), b"\rabc\x1bxyz");
180    }
181}