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}