Skip to main content

omni_cli/
cast.rs

1//! omni cast mode — stream stdin to the omni wire command stream (bd-5cd61e).
2//!
3//! `omni cast` reads stdin and forwards it, in real time, as a stream of
4//! [`crate::wire::InjectionCommand`]s to the selected sink (the NDJSON wire on
5//! stdout by default, or the Android transport with `--adb`). It is the
6//! streaming sibling of one-shot `omni send`.
7//!
8//! ## Framing ([`Frame`])
9//!
10//! - `line` (default): each input line becomes a `text` command (empty lines
11//!   are skipped). Good for `producer | omni cast`.
12//! - `char`: each input character is forwarded as it arrives — `\n`/`\r` ->
13//!   `key enter`, `\t` -> `key tab`, everything else -> a one-character `text`
14//!   command. Good for real-time keystroke forwarding.
15//! - `ndjson`: each input line is parsed as a canonical NDJSON
16//!   [`InjectionCommand`] and forwarded verbatim (a pump/forward mode), e.g.
17//!   `omni send text hi | omni cast --ndjson --adb`. Blank lines are skipped;
18//!   a malformed line is an error.
19//!
20//! The actual delivery (stdout NDJSON vs. Android am-broadcast) is the caller's
21//! `sink`; this module owns only stdin -> command translation, so it is unit
22//! testable without a real terminal or device. The caller flushes the sink per
23//! command for real-time behaviour.
24
25use std::io::{BufRead, Read};
26
27use crate::AppError;
28use crate::wire::InjectionCommand;
29
30/// How `omni cast` frames stdin into commands.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Frame {
33    /// Each input line -> one `text` command (empty lines skipped).
34    Line,
35    /// Each input character -> a `key`/`text` command, streamed as it arrives.
36    Char,
37    /// Each input line -> a parsed NDJSON `InjectionCommand`, forwarded.
38    Ndjson,
39}
40
41impl Frame {
42    /// Parse the `--frame` value.
43    ///
44    /// # Errors
45    /// [`AppError::validation`] for an unknown frame name.
46    pub fn parse(s: &str) -> Result<Self, AppError> {
47        match s {
48            "line" => Ok(Frame::Line),
49            "char" => Ok(Frame::Char),
50            "ndjson" => Ok(Frame::Ndjson),
51            other => Err(AppError::validation(
52                "cast_frame",
53                format!("unknown --frame {other} (expected line|char|ndjson)"),
54            )),
55        }
56    }
57}
58
59/// Map a single character to a command for [`Frame::Char`] streaming.
60#[must_use]
61pub fn char_to_command(ch: char) -> InjectionCommand {
62    match ch {
63        '\n' | '\r' => InjectionCommand::key("enter"),
64        '\t' => InjectionCommand::key("tab"),
65        other => InjectionCommand::text(other.to_string()),
66    }
67}
68
69/// Drive a cast: read from `input`, translate per `frame`, and hand each
70/// [`InjectionCommand`] to `sink` (which delivers + flushes it). Returns the
71/// number of commands emitted.
72///
73/// Processing is incremental (line-by-line or byte-by-byte) so a streaming
74/// producer is forwarded in real time rather than buffered to EOF.
75///
76/// # Errors
77/// [`AppError`] on a stdin read error, a malformed `ndjson` line, or a sink
78/// error (delivery failure).
79pub fn run_cast<R, F>(input: R, frame: Frame, mut sink: F) -> Result<usize, AppError>
80where
81    R: BufRead,
82    F: FnMut(&InjectionCommand) -> Result<(), AppError>,
83{
84    let mut count = 0usize;
85    match frame {
86        Frame::Line => {
87            for line in input.lines() {
88                let line = line.map_err(|e| AppError::internal("cast_read", e.to_string()))?;
89                if line.is_empty() {
90                    continue;
91                }
92                sink(&InjectionCommand::text(line))?;
93                count += 1;
94            }
95        }
96        Frame::Ndjson => {
97            for line in input.lines() {
98                let line = line.map_err(|e| AppError::internal("cast_read", e.to_string()))?;
99                if line.trim().is_empty() {
100                    continue;
101                }
102                let cmd = InjectionCommand::decode_line(&line)?;
103                sink(&cmd)?;
104                count += 1;
105            }
106        }
107        Frame::Char => {
108            // Incremental UTF-8: buffer bytes until a full character forms, emit
109            // it, and clear. Incomplete sequences keep buffering; invalid bytes
110            // resync by dropping the buffer.
111            let mut buf: Vec<u8> = Vec::with_capacity(4);
112            for byte in input.bytes() {
113                let byte = byte.map_err(|e| AppError::internal("cast_read", e.to_string()))?;
114                buf.push(byte);
115                match std::str::from_utf8(&buf) {
116                    Ok(s) => {
117                        for ch in s.chars() {
118                            sink(&char_to_command(ch))?;
119                            count += 1;
120                        }
121                        buf.clear();
122                    }
123                    Err(e) => {
124                        if e.error_len().is_some() {
125                            buf.clear();
126                        }
127                        // else: incomplete multi-byte char, keep buffering.
128                    }
129                }
130            }
131        }
132    }
133    Ok(count)
134}
135
136/// Convenience wrapper that reads from any [`Read`] (wrapping it in a
137/// [`std::io::BufReader`]).
138///
139/// # Errors
140/// See [`run_cast`].
141pub fn run_cast_reader<R, F>(reader: R, frame: Frame, sink: F) -> Result<usize, AppError>
142where
143    R: Read,
144    F: FnMut(&InjectionCommand) -> Result<(), AppError>,
145{
146    run_cast(std::io::BufReader::new(reader), frame, sink)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::wire::Kind;
153
154    fn collect(frame: Frame, input: &str) -> Result<Vec<InjectionCommand>, AppError> {
155        let mut out = Vec::new();
156        let n = run_cast(input.as_bytes(), frame, |cmd| {
157            out.push(cmd.clone());
158            Ok(())
159        })?;
160        assert_eq!(n, out.len());
161        Ok(out)
162    }
163
164    #[test]
165    fn frame_parse() {
166        assert_eq!(Frame::parse("line").unwrap(), Frame::Line);
167        assert_eq!(Frame::parse("char").unwrap(), Frame::Char);
168        assert_eq!(Frame::parse("ndjson").unwrap(), Frame::Ndjson);
169        assert_eq!(Frame::parse("bogus").unwrap_err().code(), "cast_frame");
170    }
171
172    #[test]
173    fn line_mode_one_text_per_line_skips_blanks() {
174        let cmds = collect(Frame::Line, "hello\n\nworld\n").unwrap();
175        assert_eq!(
176            cmds,
177            vec![
178                InjectionCommand::text("hello"),
179                InjectionCommand::text("world")
180            ]
181        );
182    }
183
184    #[test]
185    fn line_mode_handles_missing_trailing_newline() {
186        let cmds = collect(Frame::Line, "only-line").unwrap();
187        assert_eq!(cmds, vec![InjectionCommand::text("only-line")]);
188    }
189
190    #[test]
191    fn ndjson_mode_forwards_wire_commands() {
192        let stream = "{\"v\":1,\"type\":\"text\",\"text\":\"hi\"}\n\n{\"v\":1,\"type\":\"scroll\",\"amount\":3}\n";
193        let cmds = collect(Frame::Ndjson, stream).unwrap();
194        assert_eq!(
195            cmds,
196            vec![InjectionCommand::text("hi"), InjectionCommand::scroll(3)]
197        );
198    }
199
200    #[test]
201    fn ndjson_mode_errors_on_malformed_line() {
202        let err = collect(Frame::Ndjson, "not-json\n").unwrap_err();
203        assert_eq!(err.code(), "wire_decode");
204    }
205
206    #[test]
207    fn char_mode_maps_control_chars_and_text() {
208        let cmds = collect(Frame::Char, "hi\tx\n").unwrap();
209        assert_eq!(
210            cmds,
211            vec![
212                InjectionCommand::text("h"),
213                InjectionCommand::text("i"),
214                InjectionCommand::key("tab"),
215                InjectionCommand::text("x"),
216                InjectionCommand::key("enter"),
217            ]
218        );
219    }
220
221    #[test]
222    fn char_mode_decodes_multibyte_utf8() {
223        // 'é' (2 bytes) + '€' (3 bytes) + '!'.
224        let cmds = collect(Frame::Char, "é€!").unwrap();
225        assert_eq!(cmds.len(), 3);
226        assert_eq!(cmds[0], InjectionCommand::text("é"));
227        assert_eq!(cmds[1], InjectionCommand::text("€"));
228        assert_eq!(cmds[2], InjectionCommand::text("!"));
229        assert!(cmds.iter().all(|c| c.kind == Kind::Text));
230    }
231
232    #[test]
233    fn sink_error_propagates() {
234        let err = run_cast("a\nb\n".as_bytes(), Frame::Line, |_cmd| {
235            Err(AppError::internal("boom", "sink failed"))
236        })
237        .unwrap_err();
238        assert_eq!(err.code(), "boom");
239    }
240
241    #[test]
242    fn counts_emitted_commands() {
243        let mut n_seen = 0;
244        let n = run_cast("a\nb\nc\n".as_bytes(), Frame::Line, |_cmd| {
245            n_seen += 1;
246            Ok(())
247        })
248        .unwrap();
249        assert_eq!(n, 3);
250        assert_eq!(n_seen, 3);
251    }
252}