umsh_cli/
commands.rs

1//! Parsed-command enum and parser. The dispatcher lives in
2//! [`crate::session`].
3
4use alloc::string::String;
5
6/// A parsed CLI input line. Borrows from the input buffer — the caller is
7/// expected to own that buffer on the stack while dispatching.
8#[derive(Debug, PartialEq, Eq)]
9pub enum Command<'a> {
10    Help(Option<&'a str>),
11    Quit,
12    WhoAmI,
13
14    PeerAdd {
15        pubkey: &'a str,
16        alias: Option<&'a str>,
17    },
18    PeerAlias {
19        peer: &'a str,
20        alias: &'a str,
21    },
22    PeerRm {
23        peer: &'a str,
24    },
25    Peers,
26
27    Query {
28        peer: &'a str,
29    },
30    Msg {
31        peer: &'a str,
32        text: &'a str,
33    },
34    Text {
35        body: &'a str,
36    }, // bare line → current peer
37    Me {
38        action: &'a str,
39    },
40
41    Ping {
42        peer: &'a str,
43        bytes: Option<u16>,
44    },
45
46    PfsStart {
47        peer: &'a str,
48        minutes: Option<u16>,
49    },
50    PfsEnd {
51        peer: &'a str,
52    },
53    PfsStatus {
54        peer: Option<&'a str>,
55    },
56
57    Beacon,
58
59    ChannelJoin {
60        name: &'a str,
61        key: &'a str,
62    },
63    ChannelLeave {
64        name: &'a str,
65    },
66    ChannelSend {
67        name: &'a str,
68        text: &'a str,
69    },
70    Channels,
71
72    Stats,
73    Counters,
74    Log {
75        level: &'a str,
76    },
77    SetShow, // `/set` (no args)
78    Set {
79        var: &'a str,
80        val: &'a str,
81    },
82    Raw {
83        peer: &'a str,
84        hex: &'a str,
85    },
86
87    PowerOff,
88    Reboot,
89}
90
91#[derive(Debug, PartialEq, Eq)]
92pub enum ParseError {
93    Empty,
94    UnknownCommand(String),
95    MissingArg(&'static str),
96    BadNumber,
97}
98
99/// Parse one input line. Bare text lines (no leading `/`) become `Text`.
100pub fn parse(line: &str) -> Result<Command<'_>, ParseError> {
101    let line = line.trim();
102    if line.is_empty() {
103        return Err(ParseError::Empty);
104    }
105    if !line.starts_with('/') {
106        return Ok(Command::Text { body: line });
107    }
108    let (head, rest) = split_one(&line[1..]);
109    match head {
110        "help" | "?" => Ok(Command::Help(if rest.is_empty() {
111            None
112        } else {
113            Some(rest)
114        })),
115        "quit" | "exit" => Ok(Command::Quit),
116        "whoami" => Ok(Command::WhoAmI),
117
118        "peer" => parse_peer(rest),
119        "peers" => Ok(Command::Peers),
120        "query" => {
121            let (peer, _) = split_one(rest);
122            require(peer, "peer-ref")?;
123            Ok(Command::Query { peer })
124        }
125        "msg" => {
126            let (peer, tail) = split_one(rest);
127            require(peer, "peer-ref")?;
128            require(tail, "text")?;
129            Ok(Command::Msg { peer, text: tail })
130        }
131        "me" => {
132            require(rest, "action")?;
133            Ok(Command::Me { action: rest })
134        }
135        "ping" => {
136            let (peer, tail) = split_one(rest);
137            require(peer, "peer-ref")?;
138            let bytes = if tail.is_empty() {
139                None
140            } else {
141                Some(tail.parse::<u16>().map_err(|_| ParseError::BadNumber)?)
142            };
143            Ok(Command::Ping { peer, bytes })
144        }
145        "pfs" => parse_pfs(rest),
146        "beacon" => Ok(Command::Beacon),
147        "channel" => parse_channel(rest),
148        "channels" => Ok(Command::Channels),
149        "stats" => Ok(Command::Stats),
150        "counters" => Ok(Command::Counters),
151        "log" => {
152            require(rest, "level")?;
153            Ok(Command::Log { level: rest })
154        }
155        "set" => {
156            if rest.is_empty() {
157                return Ok(Command::SetShow);
158            }
159            let (var, val) = split_one(rest);
160            require(var, "var")?;
161            require(val, "value")?;
162            Ok(Command::Set { var, val })
163        }
164        "raw" => {
165            let (peer, hex) = split_one(rest);
166            require(peer, "peer-ref")?;
167            require(hex, "hex")?;
168            Ok(Command::Raw { peer, hex })
169        }
170        "poweroff" | "off" => Ok(Command::PowerOff),
171        "reboot" => Ok(Command::Reboot),
172
173        other => {
174            let mut s = String::new();
175            s.push_str(other);
176            Err(ParseError::UnknownCommand(s))
177        }
178    }
179}
180
181fn parse_peer(rest: &str) -> Result<Command<'_>, ParseError> {
182    let (sub, tail) = split_one(rest);
183    match sub {
184        "add" => {
185            let (pubkey, alias_part) = split_one(tail);
186            require(pubkey, "pubkey")?;
187            let alias = if alias_part.is_empty() {
188                None
189            } else {
190                Some(alias_part)
191            };
192            Ok(Command::PeerAdd { pubkey, alias })
193        }
194        "alias" => {
195            let (peer, alias) = split_one(tail);
196            require(peer, "peer-ref")?;
197            require(alias, "alias")?;
198            Ok(Command::PeerAlias { peer, alias })
199        }
200        "rm" | "remove" => {
201            let (peer, _) = split_one(tail);
202            require(peer, "peer-ref")?;
203            Ok(Command::PeerRm { peer })
204        }
205        _ => Err(ParseError::UnknownCommand(format_str("peer ", sub))),
206    }
207}
208
209fn parse_pfs(rest: &str) -> Result<Command<'_>, ParseError> {
210    let (sub, tail) = split_one(rest);
211    match sub {
212        "start" => {
213            let (peer, mins_part) = split_one(tail);
214            require(peer, "peer-ref")?;
215            let minutes = if mins_part.is_empty() {
216                None
217            } else {
218                Some(
219                    mins_part
220                        .parse::<u16>()
221                        .map_err(|_| ParseError::BadNumber)?,
222                )
223            };
224            Ok(Command::PfsStart { peer, minutes })
225        }
226        "end" => {
227            let (peer, _) = split_one(tail);
228            require(peer, "peer-ref")?;
229            Ok(Command::PfsEnd { peer })
230        }
231        "status" => {
232            let (peer, _) = split_one(tail);
233            Ok(Command::PfsStatus {
234                peer: if peer.is_empty() { None } else { Some(peer) },
235            })
236        }
237        _ => Err(ParseError::UnknownCommand(format_str("pfs ", sub))),
238    }
239}
240
241fn parse_channel(rest: &str) -> Result<Command<'_>, ParseError> {
242    let (sub, tail) = split_one(rest);
243    match sub {
244        "join" => {
245            let (name, key) = split_one(tail);
246            require(name, "channel name")?;
247            require(key, "key")?;
248            Ok(Command::ChannelJoin { name, key })
249        }
250        "leave" => {
251            let (name, _) = split_one(tail);
252            require(name, "channel name")?;
253            Ok(Command::ChannelLeave { name })
254        }
255        "send" => {
256            let (name, text) = split_one(tail);
257            require(name, "channel name")?;
258            require(text, "text")?;
259            Ok(Command::ChannelSend { name, text })
260        }
261        _ => Err(ParseError::UnknownCommand(format_str("channel ", sub))),
262    }
263}
264
265fn require(s: &str, name: &'static str) -> Result<(), ParseError> {
266    if s.is_empty() {
267        Err(ParseError::MissingArg(name))
268    } else {
269        Ok(())
270    }
271}
272
273fn split_one(s: &str) -> (&str, &str) {
274    let s = s.trim_start();
275    match s.find(|c: char| c.is_whitespace()) {
276        Some(idx) => (&s[..idx], s[idx..].trim_start()),
277        None => (s, ""),
278    }
279}
280
281fn format_str(prefix: &str, s: &str) -> String {
282    let mut out = String::new();
283    out.push_str(prefix);
284    out.push_str(s);
285    out
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn bare_text() {
294        assert_eq!(
295            parse("hello world"),
296            Ok(Command::Text {
297                body: "hello world"
298            })
299        );
300    }
301
302    #[test]
303    fn quit_and_aliases() {
304        assert_eq!(parse("/quit"), Ok(Command::Quit));
305        assert_eq!(parse("/exit"), Ok(Command::Quit));
306    }
307
308    #[test]
309    fn peer_add_with_alias() {
310        assert_eq!(
311            parse("/peer add ABCDEF bob"),
312            Ok(Command::PeerAdd {
313                pubkey: "ABCDEF",
314                alias: Some("bob")
315            })
316        );
317    }
318
319    #[test]
320    fn peer_alias() {
321        assert_eq!(
322            parse("/peer alias bob alice"),
323            Ok(Command::PeerAlias {
324                peer: "bob",
325                alias: "alice"
326            })
327        );
328    }
329
330    #[test]
331    fn ping_with_bytes() {
332        assert_eq!(
333            parse("/ping bob 64"),
334            Ok(Command::Ping {
335                peer: "bob",
336                bytes: Some(64)
337            })
338        );
339    }
340
341    #[test]
342    fn set_show_vs_set() {
343        assert_eq!(parse("/set"), Ok(Command::SetShow));
344        assert_eq!(
345            parse("/set flood_hops 3"),
346            Ok(Command::Set {
347                var: "flood_hops",
348                val: "3"
349            })
350        );
351    }
352
353    #[test]
354    fn counters() {
355        assert_eq!(parse("/counters"), Ok(Command::Counters));
356    }
357
358    #[test]
359    fn reboot() {
360        assert_eq!(parse("/reboot"), Ok(Command::Reboot));
361    }
362
363    #[test]
364    fn unknown_cmd() {
365        assert!(matches!(parse("/wut"), Err(ParseError::UnknownCommand(_))));
366    }
367}