Skip to main content

rune_morse/
lib.rs

1//! Morse code encoder and decoder — ASCII text to dots and dashes and back.
2//!
3//! Converts between ASCII text and International Morse Code. Letters are
4//! separated by a single space; words are separated by ` / `. Both uppercase
5//! and lowercase input are accepted during encoding. The library has zero
6//! dependencies and works entirely in safe, pure Rust.
7//!
8//! # Features
9//!
10//! - [`encode`] — convert ASCII text to Morse code.
11//! - [`decode`] — convert Morse code back to ASCII text.
12//! - [`MorseError`] — error type for unknown characters or unrecognised sequences.
13//!
14//! # Quick Start
15//!
16//! ```rust
17//! use rune_morse::{encode, decode};
18//!
19//! let morse = encode("SOS").unwrap();
20//! assert_eq!(morse, "... --- ...");
21//!
22//! let text = decode("... --- ...").unwrap();
23//! assert_eq!(text, "SOS");
24//! ```
25//!
26//! # CLI
27//!
28//! ```bash
29//! rune-morse encode "Hello World"
30//! rune-morse decode ".... . .-.. .-.. --- / .-- --- .-. .-.. -.."
31//! ```
32
33use std::fmt;
34
35static MORSE_TABLE: &[(char, &str)] = &[
36    ('A', ".-"),
37    ('B', "-..."),
38    ('C', "-.-."),
39    ('D', "-.."),
40    ('E', "."),
41    ('F', "..-."),
42    ('G', "--."),
43    ('H', "...."),
44    ('I', ".."),
45    ('J', ".---"),
46    ('K', "-.-"),
47    ('L', ".-.."),
48    ('M', "--"),
49    ('N', "-."),
50    ('O', "---"),
51    ('P', ".--."),
52    ('Q', "--.-"),
53    ('R', ".-."),
54    ('S', "..."),
55    ('T', "-"),
56    ('U', "..-"),
57    ('V', "...-"),
58    ('W', ".--"),
59    ('X', "-..-"),
60    ('Y', "-.--"),
61    ('Z', "--.."),
62    ('0', "-----"),
63    ('1', ".----"),
64    ('2', "..---"),
65    ('3', "...--"),
66    ('4', "....-"),
67    ('5', "....."),
68    ('6', "-...."),
69    ('7', "--..."),
70    ('8', "---.."),
71    ('9', "----."),
72];
73
74/// Error returned when encoding or decoding encounters unexpected input.
75#[derive(Debug, PartialEq, Eq)]
76pub enum MorseError {
77    /// A character in the source text has no Morse representation.
78    UnknownChar(char),
79    /// A dot-dash sequence does not match any known Morse code.
80    UnknownCode(String),
81}
82
83impl fmt::Display for MorseError {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            MorseError::UnknownChar(ch) => write!(f, "no Morse code for character: {ch:?}"),
87            MorseError::UnknownCode(code) => write!(f, "unknown Morse sequence: {code:?}"),
88        }
89    }
90}
91
92impl std::error::Error for MorseError {}
93
94/// Encodes ASCII `text` as Morse code.
95///
96/// Letters are converted to uppercase before lookup. Individual letter codes
97/// are joined by a single space; words (separated by ASCII spaces in the
98/// input) are separated by ` / ` in the output. An empty string returns an
99/// empty string without error.
100///
101/// # Errors
102///
103/// Returns [`MorseError::UnknownChar`] for any character that has no Morse
104/// representation (e.g. punctuation other than digits and letters).
105///
106/// # Examples
107///
108/// ```rust
109/// use rune_morse::encode;
110///
111/// assert_eq!(encode("SOS").unwrap(), "... --- ...");
112/// assert_eq!(encode("sos").unwrap(), "... --- ...");
113/// assert_eq!(encode("HI MOM").unwrap(), ".... .. / -- --- --");
114/// assert_eq!(encode("").unwrap(), "");
115/// ```
116pub fn encode(text: &str) -> Result<String, MorseError> {
117    if text.is_empty() {
118        return Ok(String::new());
119    }
120
121    let words: Result<Vec<String>, MorseError> = text.split(' ').map(encode_word).collect();
122
123    Ok(words?.join(" / "))
124}
125
126/// Decodes Morse code back to uppercase ASCII text.
127///
128/// Letter codes must be separated by single spaces. Word boundaries must be
129/// marked with ` / ` (space-slash-space). An empty string returns an empty
130/// string without error.
131///
132/// # Errors
133///
134/// Returns [`MorseError::UnknownCode`] for any dot-dash sequence that does
135/// not appear in the standard International Morse table.
136///
137/// # Examples
138///
139/// ```rust
140/// use rune_morse::decode;
141///
142/// assert_eq!(decode("... --- ...").unwrap(), "SOS");
143/// assert_eq!(decode(".... .. / -- --- --").unwrap(), "HI MOM");
144/// assert_eq!(decode("").unwrap(), "");
145/// ```
146pub fn decode(morse: &str) -> Result<String, MorseError> {
147    if morse.is_empty() {
148        return Ok(String::new());
149    }
150
151    morse
152        .split(" / ")
153        .map(decode_word)
154        .collect::<Result<Vec<String>, MorseError>>()
155        .map(|words| words.join(" "))
156}
157
158fn encode_word(word: &str) -> Result<String, MorseError> {
159    let codes: Result<Vec<&str>, MorseError> = word
160        .chars()
161        .map(|ch| char_to_morse(ch.to_ascii_uppercase()))
162        .collect();
163    Ok(codes?.join(" "))
164}
165
166fn decode_word(word: &str) -> Result<String, MorseError> {
167    word.split(' ').map(morse_to_char).collect()
168}
169
170fn char_to_morse(ch: char) -> Result<&'static str, MorseError> {
171    MORSE_TABLE
172        .iter()
173        .find(|(letter, _)| *letter == ch)
174        .map(|(_, code)| *code)
175        .ok_or(MorseError::UnknownChar(ch))
176}
177
178fn morse_to_char(code: &str) -> Result<char, MorseError> {
179    MORSE_TABLE
180        .iter()
181        .find(|(_, morse)| *morse == code)
182        .map(|(letter, _)| *letter)
183        .ok_or_else(|| MorseError::UnknownCode(code.to_owned()))
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn encode_sos() {
192        assert_eq!(encode("SOS").unwrap(), "... --- ...");
193    }
194
195    #[test]
196    fn encode_lowercase_treated_as_uppercase() {
197        assert_eq!(encode("sos").unwrap(), "... --- ...");
198    }
199
200    #[test]
201    fn encode_hello_world() {
202        assert_eq!(
203            encode("HELLO WORLD").unwrap(),
204            ".... . .-.. .-.. --- / .-- --- .-. .-.. -.."
205        );
206    }
207
208    #[test]
209    fn decode_sos() {
210        assert_eq!(decode("... --- ...").unwrap(), "SOS");
211    }
212
213    #[test]
214    fn decode_hello_world() {
215        assert_eq!(
216            decode(".... . .-.. .-.. --- / .-- --- .-. .-.. -..").unwrap(),
217            "HELLO WORLD"
218        );
219    }
220
221    #[test]
222    fn roundtrip_letters_and_digits() {
223        let original = "THE QUICK BROWN FOX 123";
224        let encoded = encode(original).unwrap();
225        let decoded = decode(&encoded).unwrap();
226        assert_eq!(decoded, original);
227    }
228
229    #[test]
230    fn roundtrip_lowercase_normalised() {
231        let encoded = encode("hello world").unwrap();
232        let decoded = decode(&encoded).unwrap();
233        assert_eq!(decoded, "HELLO WORLD");
234    }
235
236    #[test]
237    fn encode_unknown_char_returns_error() {
238        assert!(matches!(
239            encode("HI!").unwrap_err(),
240            MorseError::UnknownChar('!')
241        ));
242    }
243
244    #[test]
245    fn decode_unknown_code_returns_error() {
246        assert!(matches!(
247            decode("....----").unwrap_err(),
248            MorseError::UnknownCode(ref code) if code == "....----"
249        ));
250    }
251
252    #[test]
253    fn encode_empty_string() {
254        assert_eq!(encode("").unwrap(), "");
255    }
256
257    #[test]
258    fn decode_empty_string() {
259        assert_eq!(decode("").unwrap(), "");
260    }
261
262    #[test]
263    fn encode_digits() {
264        assert_eq!(encode("42").unwrap(), "....- ..---");
265    }
266
267    #[test]
268    fn decode_digits() {
269        assert_eq!(decode("....- ..---").unwrap(), "42");
270    }
271}