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/// The full set of line-ending mappers for a session's byte streams.
40///
41/// Holds one [`LineEnding`] rule per direction:
42///
43/// - `omap` — outbound (applied to bytes sent to the device)
44/// - `imap` — inbound (applied to bytes received from the device)
45/// - `emap` — echo map (applied to local echo display)
46///
47/// `Default` returns all three set to [`LineEnding::None`] — i.e. the
48/// transparent configuration that passes every byte through unchanged.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub struct LineEndingConfig {
51    /// Outbound mapper — bytes typed by the user before they reach the device.
52    pub omap: LineEnding,
53    /// Inbound mapper — bytes received from the device before they reach the screen.
54    pub imap: LineEnding,
55    /// Echo mapper — applied to the local echo of outbound bytes when echo is on.
56    pub emap: LineEnding,
57}
58
59/// Generic byte-stream transformation.
60///
61/// `&mut self` because some future mappers (e.g. one that normalises
62/// `\r\n` straddling a chunk boundary) will keep state across calls.
63/// The line-ending mapper is stateless but pays the same signature cost.
64pub trait Mapper: Send {
65    /// Transforms the input chunk and returns the result.
66    fn map(&mut self, bytes: &[u8]) -> Bytes;
67}
68
69/// Stateless byte mapper that applies a single [`LineEnding`] rule.
70#[derive(Clone, Copy, Debug, Default)]
71pub struct LineEndingMapper {
72    rule: LineEnding,
73}
74
75impl LineEndingMapper {
76    /// Builds a mapper that applies `rule` on every call to
77    /// [`Mapper::map`].
78    #[must_use]
79    pub const fn new(rule: LineEnding) -> Self {
80        Self { rule }
81    }
82
83    /// Returns the rule this mapper was configured with.
84    #[must_use]
85    pub const fn rule(&self) -> LineEnding {
86        self.rule
87    }
88}
89
90impl Mapper for LineEndingMapper {
91    fn map(&mut self, bytes: &[u8]) -> Bytes {
92        // Fast path: identity mapping copies the slice once.
93        if matches!(self.rule, LineEnding::None) {
94            return Bytes::copy_from_slice(bytes);
95        }
96        // Worst case (Add* rules) doubles every LF/CR. Reserve a hair
97        // more than the input length to avoid the first realloc on the
98        // common case of a few line endings per chunk.
99        let mut out = Vec::with_capacity(bytes.len() + 4);
100        for &byte in bytes {
101            match (self.rule, byte) {
102                // Both Add* rules expand the matched byte to CRLF.
103                (LineEnding::AddCrToLf, b'\n') | (LineEnding::AddLfToCr, b'\r') => {
104                    out.push(b'\r');
105                    out.push(b'\n');
106                }
107                (LineEnding::DropCr, b'\r') | (LineEnding::DropLf, b'\n') => {
108                    // skip
109                }
110                _ => out.push(byte),
111            }
112        }
113        Bytes::from(out)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn run(rule: LineEnding, input: &[u8]) -> Vec<u8> {
122        let mut m = LineEndingMapper::new(rule);
123        m.map(input).to_vec()
124    }
125
126    #[test]
127    fn none_passes_bytes_through_verbatim() {
128        assert_eq!(run(LineEnding::None, b""), b"");
129        assert_eq!(
130            run(LineEnding::None, b"hello\r\nworld\n"),
131            b"hello\r\nworld\n"
132        );
133    }
134
135    #[test]
136    fn default_rule_is_none() {
137        let mut m = LineEndingMapper::default();
138        assert_eq!(m.rule(), LineEnding::None);
139        assert_eq!(m.map(b"abc").to_vec(), b"abc");
140    }
141
142    #[test]
143    fn add_cr_to_lf_converts_lf_to_crlf() {
144        assert_eq!(run(LineEnding::AddCrToLf, b"hi\nyo\n"), b"hi\r\nyo\r\n");
145    }
146
147    #[test]
148    fn add_cr_to_lf_does_not_touch_existing_crlf() {
149        // The rule is "before every LF, insert CR" — so an existing CR
150        // before an LF means we get CRCRLF. That matches picocom's
151        // behaviour and keeps the rule trivially per-byte.
152        assert_eq!(run(LineEnding::AddCrToLf, b"a\r\nb"), b"a\r\r\nb");
153    }
154
155    #[test]
156    fn add_cr_to_lf_handles_consecutive_lfs() {
157        assert_eq!(run(LineEnding::AddCrToLf, b"\n\n"), b"\r\n\r\n");
158    }
159
160    #[test]
161    fn add_lf_to_cr_converts_cr_to_crlf() {
162        assert_eq!(run(LineEnding::AddLfToCr, b"hi\ryo\r"), b"hi\r\nyo\r\n");
163    }
164
165    #[test]
166    fn add_lf_to_cr_does_not_touch_existing_crlf() {
167        // Same rationale: per-byte rule, "after every CR, insert LF" — a
168        // CR already followed by LF gains a second LF.
169        assert_eq!(run(LineEnding::AddLfToCr, b"a\r\nb"), b"a\r\n\nb");
170    }
171
172    #[test]
173    fn drop_cr_removes_carriage_returns_and_keeps_other_bytes() {
174        assert_eq!(run(LineEnding::DropCr, b"a\r\nb\rc"), b"a\nbc");
175    }
176
177    #[test]
178    fn drop_lf_removes_line_feeds_and_keeps_other_bytes() {
179        assert_eq!(run(LineEnding::DropLf, b"a\r\nb\nc"), b"a\rbc");
180    }
181
182    #[test]
183    fn empty_input_yields_empty_output_for_every_rule() {
184        for rule in [
185            LineEnding::None,
186            LineEnding::AddCrToLf,
187            LineEnding::AddLfToCr,
188            LineEnding::DropCr,
189            LineEnding::DropLf,
190        ] {
191            assert!(run(rule, b"").is_empty(), "{rule:?} on empty input");
192        }
193    }
194
195    #[test]
196    fn add_cr_to_lf_leaves_non_lf_bytes_alone() {
197        // The mapper must not touch CR or arbitrary bytes when the
198        // active rule targets only LF.
199        assert_eq!(run(LineEnding::AddCrToLf, b"\rabc\x1bxyz"), b"\rabc\x1bxyz");
200    }
201
202    #[test]
203    fn line_ending_config_default_all_none() {
204        let c = LineEndingConfig::default();
205        assert_eq!(c.omap, LineEnding::None);
206        assert_eq!(c.imap, LineEnding::None);
207        assert_eq!(c.emap, LineEnding::None);
208    }
209}