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}