1use std::io::{BufRead, Read};
26
27use crate::AppError;
28use crate::wire::InjectionCommand;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Frame {
33 Line,
35 Char,
37 Ndjson,
39}
40
41impl Frame {
42 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#[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
69pub 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 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 }
129 }
130 }
131 }
132 }
133 Ok(count)
134}
135
136pub 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 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}