1use std::time::Duration;
21
22use crate::AppError;
23use crate::wire::{InjectionCommand, parse_send_tokens};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum MacroStep {
29 Command(InjectionCommand),
31 Wait(Duration),
33}
34
35pub fn parse_macro(input: &str) -> Result<Vec<MacroStep>, AppError> {
46 let mut steps = Vec::new();
47 for (idx, raw) in input.lines().enumerate() {
48 let line_no = idx + 1;
49 let line = raw.trim();
50 if line.is_empty() || line.starts_with('#') {
51 continue;
52 }
53 let tokens: Vec<String> = line.split_whitespace().map(str::to_owned).collect();
54 if tokens[0] == "wait" {
55 steps.push(MacroStep::Wait(Duration::from_millis(parse_wait(
56 &tokens, line_no,
57 )?)));
58 } else {
59 let cmd = parse_send_tokens(&tokens)
60 .map_err(|e| AppError::validation("macro_line", format!("line {line_no}: {e}")))?;
61 steps.push(MacroStep::Command(cmd));
62 }
63 }
64 Ok(steps)
65}
66
67#[must_use]
74pub fn batch_steps(steps: &[MacroStep]) -> Vec<(Vec<InjectionCommand>, Duration)> {
75 let mut batches = Vec::new();
76 let mut current = Vec::new();
77 for step in steps {
78 match step {
79 MacroStep::Command(cmd) => current.push(cmd.clone()),
80 MacroStep::Wait(delay) => batches.push((std::mem::take(&mut current), *delay)),
81 }
82 }
83 if !current.is_empty() {
84 batches.push((current, Duration::ZERO));
85 }
86 batches
87}
88
89fn parse_wait(tokens: &[String], line_no: usize) -> Result<u64, AppError> {
90 match tokens {
91 [_, ms] => ms.parse::<u64>().map_err(|_| {
92 AppError::validation(
93 "macro_wait",
94 format!("line {line_no}: wait amount must be a non-negative integer in ms: {ms}"),
95 )
96 }),
97 _ => Err(AppError::validation(
98 "macro_wait_arity",
99 format!("line {line_no}: wait requires exactly one value (milliseconds)"),
100 )),
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn parses_commands_waits_comments_and_blanks() {
110 let src = "\
111# a keyboard macro
112text hello world
113wait 200
114key enter
115
116 # indented comment
117combo ctrl,a,g
118scroll -3
119";
120 let steps = parse_macro(src).unwrap();
121 assert_eq!(
122 steps,
123 vec![
124 MacroStep::Command(InjectionCommand::text("hello world")),
125 MacroStep::Wait(Duration::from_millis(200)),
126 MacroStep::Command(InjectionCommand::key("enter")),
127 MacroStep::Command(InjectionCommand::combo("ctrl,a,g")),
128 MacroStep::Command(InjectionCommand::scroll(-3)),
129 ]
130 );
131 }
132
133 #[test]
134 fn hash_inside_text_is_not_a_comment() {
135 let steps = parse_macro("text ping #general").unwrap();
137 assert_eq!(
138 steps,
139 vec![MacroStep::Command(InjectionCommand::text("ping #general"))]
140 );
141 }
142
143 #[test]
144 fn empty_input_is_no_steps() {
145 assert!(parse_macro("").unwrap().is_empty());
146 assert!(parse_macro("\n \n# only comments\n").unwrap().is_empty());
147 }
148
149 #[test]
150 fn wait_errors_are_line_tagged() {
151 assert_eq!(parse_macro("wait").unwrap_err().code(), "macro_wait_arity");
152 assert_eq!(
153 parse_macro("wait 1 2").unwrap_err().code(),
154 "macro_wait_arity"
155 );
156 let err = parse_macro("text ok\nwait nope").unwrap_err();
157 assert_eq!(err.code(), "macro_wait");
158 assert!(err.to_string().contains("line 2"), "want line 2 in: {err}");
159 }
160
161 #[test]
162 fn bad_command_line_is_line_tagged() {
163 let err = parse_macro("text ok\n\nbogus xyz").unwrap_err();
164 assert_eq!(err.code(), "macro_line");
165 assert!(err.to_string().contains("line 3"), "want line 3 in: {err}");
166 }
167
168 #[test]
169 fn batch_groups_commands_between_waits() {
170 let steps = parse_macro("text a\nkey b\nwait 100\nscroll 2\n").unwrap();
171 let batches = batch_steps(&steps);
172 assert_eq!(
173 batches,
174 vec![
175 (
176 vec![InjectionCommand::text("a"), InjectionCommand::key("b")],
177 Duration::from_millis(100)
178 ),
179 (vec![InjectionCommand::scroll(2)], Duration::ZERO),
180 ]
181 );
182 }
183
184 #[test]
185 fn batch_preserves_leading_and_standalone_wait() {
186 let steps = parse_macro("wait 50\ntext a\nwait 10\n").unwrap();
187 let batches = batch_steps(&steps);
188 assert_eq!(
189 batches,
190 vec![
191 (vec![], Duration::from_millis(50)),
192 (vec![InjectionCommand::text("a")], Duration::from_millis(10)),
193 ]
194 );
195 }
196}