Skip to main content

happy_cracking/crypto/
tapcode.rs

1use anyhow::Result;
2use clap::Subcommand;
3
4#[derive(Subcommand)]
5pub enum TapcodeAction {
6    #[command(about = "Encode text to tap code")]
7    Encode {
8        #[arg(help = "Input text")]
9        input: String,
10    },
11    #[command(about = "Decode tap code to text")]
12    Decode {
13        #[arg(help = "Tap code (dots separated by spaces, words separated by /)")]
14        input: String,
15    },
16}
17
18pub fn run(action: TapcodeAction) -> Result<()> {
19    match action {
20        TapcodeAction::Encode { input } => {
21            println!("{}", encode(&input));
22        }
23        TapcodeAction::Decode { input } => {
24            println!("{}", decode(&input)?);
25        }
26    }
27    Ok(())
28}
29
30// 5x5 grid: A B C/K D E / F G H I J / L M N O P / Q R S T U / V W X Y Z
31const GRID: [char; 25] = [
32    'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T',
33    'U', 'V', 'W', 'X', 'Y', 'Z',
34];
35
36const DOTS: [&str; 6] = ["", ".", "..", "...", "....", "....."];
37
38pub fn encode(input: &str) -> String {
39    let mut out = String::with_capacity(input.len() * 10);
40    let mut first_word = true;
41
42    for word in input.split_whitespace() {
43        if !first_word {
44            out.push_str(" / ");
45        }
46        first_word = false;
47
48        let mut first_char = true;
49        for c in word.bytes() {
50            if !c.is_ascii_alphabetic() {
51                continue;
52            }
53            let mut b = c.to_ascii_uppercase();
54            if b == b'K' {
55                b = b'C';
56            }
57
58            let idx = b - b'A';
59            let (row, col) = match b {
60                b'A'..=b'I' => (idx / 5 + 1, idx % 5 + 1),
61                b'K'..=b'Z' => {
62                    let shifted = idx - 1;
63                    (shifted / 5 + 1, shifted % 5 + 1)
64                }
65                b'J' => (2, 5),
66                _ => continue,
67            };
68
69            if !first_char {
70                out.push_str("   ");
71            }
72            first_char = false;
73
74            out.push_str(DOTS[row as usize]);
75            out.push(' ');
76            out.push_str(DOTS[col as usize]);
77        }
78    }
79    out
80}
81
82pub fn decode(input: &str) -> Result<String> {
83    if input.trim().is_empty() {
84        return Ok(String::new());
85    }
86
87    let mut result = String::with_capacity(input.len() / 3);
88    let mut first_word = true;
89
90    for word in input.split(" / ") {
91        if !first_word {
92            result.push(' ');
93        }
94        first_word = false;
95
96        for pair in word.split("   ").filter(|s| !s.is_empty()) {
97            let mut parts = pair.split(' ').filter(|s| !s.is_empty());
98            let row_str = parts
99                .next()
100                .ok_or_else(|| anyhow::anyhow!("Invalid tap code pair: {}", pair))?;
101            let col_str = parts
102                .next()
103                .ok_or_else(|| anyhow::anyhow!("Invalid tap code pair: {}", pair))?;
104
105            if parts.next().is_some() {
106                anyhow::bail!("Invalid tap code pair: {}", pair);
107            }
108
109            let row = row_str.len();
110            let col = col_str.len();
111
112            if !(1..=5).contains(&row) || !(1..=5).contains(&col) {
113                anyhow::bail!("Tap code values out of range (1-5): {}", pair);
114            }
115            if !row_str.chars().all(|c| c == '.') || !col_str.chars().all(|c| c == '.') {
116                anyhow::bail!("Invalid tap code characters: {}", pair);
117            }
118
119            let idx = (row - 1) * 5 + (col - 1);
120            result.push(GRID[idx]);
121        }
122    }
123
124    Ok(result)
125}