Morse Code in Rust: Build a CLI Encoder and Decoder

Morse code is a small problem with a sharp edge. Encoding is a dictionary lookup; decoding is a dictionary lookup in reverse — until you add timing, and then it becomes a small state machine with real failure modes. Rust is an unusually good language for this shape of problem: enums model the signal states, Result forces you to decide what happens to bad input, and the whole tool compiles to a single binary you can hand to anyone.

In this article we will build a complete command-line Morse tool in Rust: encode text to dots and dashes, decode dots and dashes back to text, and handle the timing rules that make a decoder actually work. No external crates — just the standard library.

The table, as data

Every Morse implementation starts with the same table. In Rust it is a pair of match arms, or a static map. A match is the honest choice here: the compiler will tell you if you forgot a letter.

fn encode_char(ch: char) -> Option<&'static str> { Some(match ch { 'A' => ".-", 'B' => "-...", 'C' => "-.-.", 'D' => "-..", 'E' => ".", 'F' => "..-.", 'G' => "--.", 'H' => "....", 'I' => "..", 'J' => ".---", 'K' => "-.-", 'L' => ".-..", 'M' => "--", 'N' => "-.", 'O' => "---", 'P' => ".--.", 'Q' => "--.-", 'R' => ".-.", 'S' => "...", 'T' => "-", 'U' => "..-", 'V' => "...-", 'W' => ".--", 'X' => "-..-", 'Y' => "-.--", 'Z' => "--..", '0' => "-----", '1' => ".----", '2' => "..---", '3' => "...--", '4' => "....-", '5' => ".....", '6' => "-....", '7' => "--...", '8' => "---..", '9' => "----.", '.' => ".-.-.-", ',' => "--..--", '?' => "..--..", _ => return None, }) } 

Returning Option is a deliberate design decision. Encoding has a failure mode — characters with no code — and Rust makes you name it. The caller decides whether to skip the character, warn, or abort. Silently dropping input is how encoders produce messages that decode to something else entirely.

Encoding: the easy direction

Encoding is a fold over the input. Letters are separated by a space, words by a slash — the convention every Morse tool uses, because without separators decoding is ambiguous (... could be S or EEE).

pub fn encode(text: &str) -> Result<String, char> { let mut out = String::new(); for word in text.to_uppercase().split_whitespace() { if !out.is_empty() { out.push_str(" / "); } let mut first = true; for ch in word.chars() { let code = encode_char(ch).ok_or(ch)?; if !first { out.push(' '); } out.push_str(code); first = false; } } Ok(out) } 

The error type is char — the offending character itself. That is enough information for the CLI to print a useful message ("no Morse code for '@'") instead of a generic failure. Result<String, char> is small, honest, and testable.

Decoding: where the timing lives

Decoding symbols is the reverse lookup, but real Morse input is not symbols — it is timings. A dit is one unit long, a dah is three, the gap inside a character is one unit, between characters three, and between words seven. At N words per minute, one unit is 1200 / N milliseconds (the PARIS standard word is 50 units long).

A decoder that takes pre-split symbols is a lookup table. A decoder that takes raw timings is a state machine. Build both, and keep them separate — the symbol decoder is pure and unit-testable, the timing classifier is where the bugs live.

fn decode_char(code: &str) -> Option<char> { Some(match code { ".-" => 'A', "-..." => 'B', "-.-." => 'C', "-.." => 'D', "." => 'E', "..-." => 'F', "--." => 'G', "...." => 'H', ".." => 'I', ".---" => 'J', "-.-" => 'K', ".-.." => 'L', "--" => 'M', "-." => 'N', "---" => 'O', ".--." => 'P', "--.-" => 'Q', ".-." => 'R', "..." => 'S', "-" => 'T', "..-" => 'U', "...-" => 'V', ".--" => 'W', "-..-" => 'X', "-.--" => 'Y', "--.." => 'Z', "-----" => '0', ".----" => '1', "..---" => '2', "...--" => '3', "....-" => '4', "....." => '5', "-...." => '6', "--..." => '7', "---.." => '8', "----." => '9', ".-.-.-" => '.', "--..--" => ',', "..--.." => '?', _ => return None, }) } pub fn decode(code: &str) -> String { code.split(" / ") .map(|word| { word.split_whitespace() .map(|c| decode_char(c).unwrap_or('\u{FFFD}')) .collect::<String>() }) .collect::<Vec<_>>() .join(" ") } 

The replacement character on an unknown sequence is deliberate. A practice tool should not crash because a beginner fumbled a character, but the error should be visible in the transcript so the sender can see exactly where the timing went wrong.

The timing classifier

The interesting piece is turning a stream of on/off durations into symbols. Model the classifier's state as an enum — this is where Rust earns its keep:

#[derive(Debug, Clone, Copy, PartialEq)] enum Mark { Dit, Dah, } fn classify_mark(duration_ms: u64, unit_ms: u64) -> Mark { // Midpoint rule: under two units is a dit, over is a dah. if duration_ms < 2 * unit_ms { Mark::Dit } else { Mark::Dah } } fn classify_gap(duration_ms: u64, unit_ms: u64) -> Gap { if duration_ms >= 5 * unit_ms { Gap::Word } else if duration_ms >= 2 * unit_ms { Gap::Letter } else { Gap::IntraChar } } #[derive(Debug, Clone, Copy, PartialEq)] enum Gap { IntraChar, Letter, Word, } 

Two thresholds, both derived from the unit length, and both at midpoints — the classification is forgiving of human unevenness, which is the whole point. A fixed 1-unit/3-unit split would misread every slightly long dit; the midpoint rule tolerates sloppy sending.

The unit itself should not be hard-coded. Real senders speed up and slow down, and a decoder that assumes 20 WPM will misread a nervous beginner at 10 WPM. Track a running estimate instead: dits are the most common element in English Morse (E, a single dit, is the most frequent letter), so the shortest marks in a rolling window are almost always dits. Fold short marks into the estimate with an 80/20 weighting and derive the unit from it. The decoder then adapts to the sender instead of demanding the sender adapt to it.

The CLI: small, explicit, no dependencies

The command-line interface is intentionally boring. Two subcommands, encode and decode, reading from arguments or stdin:

use std::env; use std::io::{self, Read}; fn main() { let args: Vec<String> = env::args().collect(); match args.get(1).map(String::as_str) { Some("encode") => { let text = read_input(&args); match encode(&text) { Ok(code) => println!("{code}"), Err(ch) => { eprintln!("error: no Morse code for '{ch}'"); std::process::exit(1); } } } Some("decode") => { let code = read_input(&args); println!("{}", decode(&code)); } _ => { eprintln!("usage: morse <encode|decode> [text]"); eprintln!(" with no text, reads from stdin"); std::process::exit(2); } } } fn read_input(args: &[String]) -> String { if args.len() > 2 { args[2..].join(" ") } else { let mut buf = String::new(); io::stdin().read_to_string(&mut buf).expect("failed to read stdin"); buf.trim().to_string() } } 

Usage is exactly what you would expect:

$ morse encode "hello world" .... . .-.. .-.. --- / .-- --- .-. .-.. -.. $ morse decode ".... . .-.. .-.. ---" HELLO $ echo "SOS" | morse encode ... --- ... 

Exit codes matter: 1 for bad input, 2 for bad usage, 0 for success. A CLI that always exits zero is lying to the scripts that call it.

Testing: the round-trip property

The strongest test for any encoder/decoder pair is the round-trip property: for any string of encodable characters, decoding the encoding must return the original string. In Rust this is a plain unit test, and it catches more bugs than a dozen examples:

#[cfg(test)] mod tests { use super::*; #[test] fn round_trip() { for text in ["HELLO WORLD", "SOS", "THE QUICK BROWN FOX", "MORSE 123"] { let encoded = encode(text).unwrap(); assert_eq!(decode(&encoded), text); } } #[test] fn unknown_char_is_an_error() { assert_eq!(encode("HELLO@"), Err('@')); } #[test] fn unknown_sequence_is_visible() { // "........" is not a letter; the decoder shows it, not hides it. assert!(decode("........").contains('\u{FFFD}')); } #[test] fn timing_thresholds() { // At 20 WPM one unit is 60 ms; the dit/dah boundary is 120 ms. assert_eq!(classify_mark(59, 60), Mark::Dit); assert_eq!(classify_mark(121, 60), Mark::Dah); assert_eq!(classify_gap(59, 60), Gap::IntraChar); assert_eq!(classify_gap(200, 60), Gap::Letter); assert_eq!(classify_gap(400, 60), Gap::Word); } } 

Run it with cargo test. If you want to go further, add a property test with random strings and jittered timings — encode a random word, convert it to timed events, fuzz the timings by ±20%, decode, and demand the original back. That is the test that finds threshold bugs, and it is the same test every serious Morse decoder needs regardless of language.

What Rust teaches about this problem

Three ideas from this project transfer directly to production Rust work.

First, make failure a type, not a surprise. Option for a missing code and Result<String, char> for a bad character mean the failure modes are visible in the function signatures. Nothing panics on ordinary input, and the CLI's error message names the exact character that broke the encode.

Second, enums beat booleans for state. Mark::Dit | Mark::Dah and Gap::IntraChar | Gap::Letter | Gap::Word cannot be misread the way a bool or a magic number can, and the compiler's exhaustiveness checking means adding a state later forces you to handle it everywhere.

Third, separate the pure from the temporal. The symbol decoder is pure — same input, same output, trivially testable. The timing classifier owns all the clock-dependent judgement. When a decode goes wrong, that boundary tells you which half to blame.

Try it yourself

The whole tool is under 200 lines with no dependencies: cargo new morse, paste the pieces above, cargo run -- encode "YOUR NAME HERE". Check your output against a free Morse code translator to confirm the encoding matches before you wire up a key or a microphone. Then add the timing classifier, feed it hand-written durations, and watch your own name come back as text.

A 180-year-old code, a modern language, and a CLI you can actually use — that is a good weekend project.

Morse Zone (morsezone.com) builds free Morse code tools and learning guides — a handy reference for checking your encoder's output against known-good text.