Skip to main content

omni_cli/
macros.rs

1//! omni macro runner — parse a human-writable keystroke macro file into an
2//! ordered list of steps (semantic commands + inter-step waits) for `omni run`
3//! (bd-fdb154).
4//!
5//! A macro file is one directive per line:
6//!
7//! ```text
8//! # a comment line (full-line only; ignored)
9//! text hello world     # -> a .text command ("hello world")
10//! wait 200             # -> sleep 200ms before the next step
11//! key enter            # -> a .key command
12//! combo ctrl,a,g       # -> a .combo command
13//! scroll 3             # -> a .scroll command
14//! ```
15//!
16//! Command lines reuse [`crate::wire::parse_send_tokens`]; the macro layer adds
17//! only the `wait <ms>` directive, full-line `#` comments, and blank-line
18//! skipping — it introduces no new wire types and does not touch the transports.
19
20use std::time::Duration;
21
22use crate::AppError;
23use crate::wire::{InjectionCommand, parse_send_tokens};
24
25/// One step in a parsed macro: either a command to dispatch, or a delay to
26/// apply before the following step.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum MacroStep {
29    /// A semantic injection command (text/key/combo/scroll).
30    Command(InjectionCommand),
31    /// A delay before the next step (from a `wait <ms>` directive).
32    Wait(Duration),
33}
34
35/// Parse a macro file body into an ordered list of [`MacroStep`]s.
36///
37/// Blank lines and full-line `#` comments are skipped (a `#` is only a comment
38/// when it is the first non-whitespace character, so `text a#b` keeps its `#`).
39/// A `wait <ms>` line becomes a [`MacroStep::Wait`]; every other line is parsed
40/// as a semantic command via [`parse_send_tokens`].
41///
42/// # Errors
43/// Returns [`AppError::validation`] on the first malformed line, tagged with the
44/// 1-based line number for a precise diagnostic.
45pub 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/// Group parsed steps into `(commands, delay_after)` batches so consecutive
68/// commands dispatch together and only `wait` boundaries incur a sleep.
69///
70/// Faithful to the source timing: a leading/standalone `wait` yields an
71/// empty-command batch carrying the delay, and a trailing run of commands
72/// yields a final batch with a zero delay.
73#[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        // '#' is only a comment when it starts the line; a text payload keeps it.
136        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}