Skip to main content

rtcom_tui/
profile_bridge.rs

1//! Conversion helpers between [`rtcom_config`]'s string-based
2//! [`Profile`] and [`rtcom_core`]'s typed [`SerialConfig`] /
3//! [`LineEndingConfig`].
4//!
5//! The profile layer persists each field as a stable TOML string
6//! (`"none"`, `"even"`, `"crlf"`, ...) so hand-edited files survive
7//! refactors in `rtcom-core`'s enums. This module bridges the two
8//! representations in both directions:
9//!
10//! - [`serial_section_to_config`] / [`serial_config_to_section`] —
11//!   serial framing round-trip.
12//! - [`line_endings_from_profile`] — pulls the three line-ending
13//!   mappers out of a profile's `[line_endings]` section.
14//!
15//! Unknown strings fall through to sensible defaults rather than
16//! erroring: a hand-edited `parity = "quantum"` must never crash rtcom.
17//!
18//! Both `rtcom-cli` (at startup) and `rtcom-tui::run` (when applying
19//! `DialogAction::ReadProfile` or writing back on `ApplyAndSave`) lean
20//! on these helpers, which is why they live in the shared `rtcom-tui`
21//! crate.
22
23use rtcom_config::{
24    profile::{LineEndingsSection, SerialSection},
25    Profile,
26};
27use rtcom_core::{
28    DataBits, FlowControl, LineEnding, LineEndingConfig, Parity, SerialConfig, StopBits,
29    DEFAULT_READ_TIMEOUT,
30};
31
32/// Project a profile `[serial]` section into a runtime [`SerialConfig`].
33///
34/// Unknown parity / flow strings and out-of-range data / stop bit counts
35/// fall through to their type-level defaults rather than erroring.
36#[must_use]
37pub fn serial_section_to_config(s: &SerialSection) -> SerialConfig {
38    SerialConfig {
39        baud_rate: s.baud,
40        data_bits: parse_data_bits(s.data_bits),
41        stop_bits: parse_stop_bits(s.stop_bits),
42        parity: parse_parity(&s.parity),
43        flow_control: parse_flow(&s.flow),
44        read_timeout: DEFAULT_READ_TIMEOUT,
45    }
46}
47
48/// Project a runtime [`SerialConfig`] back into its TOML-facing
49/// [`SerialSection`] representation (used when persisting on `--save`
50/// or `DialogAction::ApplyAndSave`).
51#[must_use]
52pub fn serial_config_to_section(c: &SerialConfig) -> SerialSection {
53    SerialSection {
54        baud: c.baud_rate,
55        data_bits: c.data_bits.bits(),
56        stop_bits: stop_bits_number(c.stop_bits),
57        parity: parity_word(c.parity).into(),
58        flow: flow_word(c.flow_control).into(),
59    }
60}
61
62/// Pull the three line-ending mappers out of a profile's
63/// `[line_endings]` section. Unknown strings fall through to
64/// [`LineEnding::None`].
65#[must_use]
66pub fn line_endings_from_profile(p: &Profile) -> LineEndingConfig {
67    LineEndingConfig {
68        omap: parse_line_ending(&p.line_endings.omap),
69        imap: parse_line_ending(&p.line_endings.imap),
70        emap: parse_line_ending(&p.line_endings.emap),
71    }
72}
73
74/// Project a runtime [`LineEndingConfig`] into its TOML-facing
75/// [`LineEndingsSection`] representation (used when persisting on
76/// `DialogAction::ApplyLineEndingsAndSave`).
77///
78/// The emitted strings round-trip through [`parse_line_ending`] — the
79/// vocabulary is `"none"`, `"crlf"`, `"lfcr"`, `"igncr"`, `"ignlf"`.
80#[must_use]
81pub fn line_ending_config_to_section(c: &LineEndingConfig) -> LineEndingsSection {
82    LineEndingsSection {
83        omap: line_ending_word(c.omap).into(),
84        imap: line_ending_word(c.imap).into(),
85        emap: line_ending_word(c.emap).into(),
86    }
87}
88
89const fn line_ending_word(le: LineEnding) -> &'static str {
90    match le {
91        LineEnding::None => "none",
92        LineEnding::AddCrToLf => "crlf",
93        LineEnding::AddLfToCr => "lfcr",
94        LineEnding::DropCr => "igncr",
95        LineEnding::DropLf => "ignlf",
96    }
97}
98
99fn parse_parity(s: &str) -> Parity {
100    match s.to_ascii_lowercase().as_str() {
101        "even" => Parity::Even,
102        "odd" => Parity::Odd,
103        "mark" => Parity::Mark,
104        "space" => Parity::Space,
105        _ => Parity::None,
106    }
107}
108
109fn parse_flow(s: &str) -> FlowControl {
110    match s.to_ascii_lowercase().as_str() {
111        "hw" | "hardware" | "rtscts" => FlowControl::Hardware,
112        "sw" | "software" | "xonxoff" => FlowControl::Software,
113        _ => FlowControl::None,
114    }
115}
116
117const fn parse_data_bits(n: u8) -> DataBits {
118    match n {
119        5 => DataBits::Five,
120        6 => DataBits::Six,
121        7 => DataBits::Seven,
122        _ => DataBits::Eight,
123    }
124}
125
126const fn parse_stop_bits(n: u8) -> StopBits {
127    match n {
128        2 => StopBits::Two,
129        _ => StopBits::One,
130    }
131}
132
133/// Parse a profile-string line-ending rule into [`LineEnding`].
134/// Unknown strings fall through to [`LineEnding::None`].
135#[must_use]
136pub fn parse_line_ending(s: &str) -> LineEnding {
137    match s.to_ascii_lowercase().as_str() {
138        "crlf" => LineEnding::AddCrToLf,
139        "lfcr" => LineEnding::AddLfToCr,
140        "igncr" => LineEnding::DropCr,
141        "ignlf" => LineEnding::DropLf,
142        _ => LineEnding::None,
143    }
144}
145
146const fn parity_word(p: Parity) -> &'static str {
147    match p {
148        Parity::None => "none",
149        Parity::Even => "even",
150        Parity::Odd => "odd",
151        Parity::Mark => "mark",
152        Parity::Space => "space",
153    }
154}
155
156const fn flow_word(f: FlowControl) -> &'static str {
157    match f {
158        FlowControl::None => "none",
159        FlowControl::Hardware => "hw",
160        FlowControl::Software => "sw",
161    }
162}
163
164const fn stop_bits_number(s: StopBits) -> u8 {
165    match s {
166        StopBits::One => 1,
167        StopBits::Two => 2,
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn serial_section_round_trip() {
177        let original = SerialConfig {
178            baud_rate: 9600,
179            data_bits: DataBits::Seven,
180            stop_bits: StopBits::Two,
181            parity: Parity::Even,
182            flow_control: FlowControl::Hardware,
183            read_timeout: DEFAULT_READ_TIMEOUT,
184        };
185        let section = serial_config_to_section(&original);
186        let back = serial_section_to_config(&section);
187        assert_eq!(back.baud_rate, original.baud_rate);
188        assert_eq!(back.data_bits, original.data_bits);
189        assert_eq!(back.stop_bits, original.stop_bits);
190        assert_eq!(back.parity, original.parity);
191        assert_eq!(back.flow_control, original.flow_control);
192    }
193
194    #[test]
195    fn unknown_parity_string_falls_back_to_none() {
196        let section = SerialSection {
197            parity: "quantum".into(),
198            ..SerialSection::default()
199        };
200        let cfg = serial_section_to_config(&section);
201        assert_eq!(cfg.parity, Parity::None);
202    }
203
204    #[test]
205    fn unknown_flow_string_falls_back_to_none() {
206        let section = SerialSection {
207            flow: "teleport".into(),
208            ..SerialSection::default()
209        };
210        let cfg = serial_section_to_config(&section);
211        assert_eq!(cfg.flow_control, FlowControl::None);
212    }
213
214    #[test]
215    fn out_of_range_data_bits_fall_back_to_eight() {
216        let section = SerialSection {
217            data_bits: 42,
218            ..SerialSection::default()
219        };
220        let cfg = serial_section_to_config(&section);
221        assert_eq!(cfg.data_bits, DataBits::Eight);
222    }
223
224    #[test]
225    fn out_of_range_stop_bits_fall_back_to_one() {
226        let section = SerialSection {
227            stop_bits: 9,
228            ..SerialSection::default()
229        };
230        let cfg = serial_section_to_config(&section);
231        assert_eq!(cfg.stop_bits, StopBits::One);
232    }
233
234    #[test]
235    fn line_endings_from_profile_reads_all_three_slots() {
236        let mut profile = Profile::default();
237        profile.line_endings.omap = "crlf".into();
238        profile.line_endings.imap = "igncr".into();
239        profile.line_endings.emap = "lfcr".into();
240        let le = line_endings_from_profile(&profile);
241        assert_eq!(le.omap, LineEnding::AddCrToLf);
242        assert_eq!(le.imap, LineEnding::DropCr);
243        assert_eq!(le.emap, LineEnding::AddLfToCr);
244    }
245
246    #[test]
247    fn line_endings_from_profile_default_is_all_none() {
248        let profile = Profile::default();
249        let le = line_endings_from_profile(&profile);
250        assert_eq!(le.omap, LineEnding::None);
251        assert_eq!(le.imap, LineEnding::None);
252        assert_eq!(le.emap, LineEnding::None);
253    }
254
255    #[test]
256    fn parse_line_ending_covers_all_known_forms() {
257        assert_eq!(parse_line_ending("crlf"), LineEnding::AddCrToLf);
258        assert_eq!(parse_line_ending("lfcr"), LineEnding::AddLfToCr);
259        assert_eq!(parse_line_ending("igncr"), LineEnding::DropCr);
260        assert_eq!(parse_line_ending("ignlf"), LineEnding::DropLf);
261        assert_eq!(parse_line_ending("none"), LineEnding::None);
262        assert_eq!(parse_line_ending("bogus"), LineEnding::None);
263    }
264
265    #[test]
266    fn line_ending_config_to_section_round_trips() {
267        let original = LineEndingConfig {
268            omap: LineEnding::AddCrToLf,
269            imap: LineEnding::DropLf,
270            emap: LineEnding::None,
271        };
272        let section = line_ending_config_to_section(&original);
273        let back = line_endings_from_profile(&Profile {
274            line_endings: section,
275            ..Profile::default()
276        });
277        assert_eq!(back.omap, LineEnding::AddCrToLf);
278        assert_eq!(back.imap, LineEnding::DropLf);
279        assert_eq!(back.emap, LineEnding::None);
280    }
281
282    #[test]
283    fn line_ending_config_to_section_emits_known_vocabulary() {
284        let cfg = LineEndingConfig {
285            omap: LineEnding::AddCrToLf,
286            imap: LineEnding::AddLfToCr,
287            emap: LineEnding::DropCr,
288        };
289        let section = line_ending_config_to_section(&cfg);
290        assert_eq!(section.omap, "crlf");
291        assert_eq!(section.imap, "lfcr");
292        assert_eq!(section.emap, "igncr");
293    }
294
295    #[test]
296    fn serial_config_to_section_emits_stable_strings() {
297        let cfg = SerialConfig {
298            baud_rate: 9600,
299            data_bits: DataBits::Seven,
300            stop_bits: StopBits::Two,
301            parity: Parity::Even,
302            flow_control: FlowControl::Hardware,
303            read_timeout: DEFAULT_READ_TIMEOUT,
304        };
305        let section = serial_config_to_section(&cfg);
306        assert_eq!(section.baud, 9600);
307        assert_eq!(section.data_bits, 7);
308        assert_eq!(section.stop_bits, 2);
309        assert_eq!(section.parity, "even");
310        assert_eq!(section.flow, "hw");
311    }
312}