interactive_actions/
lib.rs

1//!
2//! `interactive-actions` is a library for automating running scripts and human interactions in a declarative way.
3//!
4//! ## Actions
5//! This crate uses a set of [`Action`]s to describe a workflow. You can give it a
6//! `name`, custom script to run with `run`, and an [`Interaction`][data::Interaction] for interacting against
7//! a human.
8//!
9//! You also have additional control flags such as `ignore_exit`, `capture` and others. See below:
10//!
11//!
12//! ## Examples
13//! Run a script conditionally, only after confirming:
14//!
15//! ```no_run
16//! use interactive_actions::data::{Action, ActionHook, VarBag};
17//! use interactive_actions::ActionRunner;
18//! use std::path::Path;
19//!
20//! let actions_defs: Vec<Action> = serde_yaml::from_str(
21//! r#"
22//! - name: confirm-action
23//!   interaction:
24//!     kind: confirm
25//!     prompt: are you sure?
26//!   run: echo hello
27//! "#).unwrap();
28//!
29//! let mut actions = ActionRunner::default();
30//! let mut v = VarBag::new();
31//! // give it a current working folder `.` and a progress function
32//! actions.run(&actions_defs, Some(Path::new(".")), &mut v, ActionHook::After, Some(|action: &Action| { println!("running: {:?}", action) }));
33//!```
34//!
35//! Describe a set of actions and interactive prompting, optionally using input variable capture,
36//! and then run everything interatively.
37//!
38//!
39//! ```no_run
40//! use interactive_actions::data::{Action, ActionHook, VarBag};
41//! use interactive_actions::ActionRunner;
42//! use std::path::Path;
43//!
44//! let actions_defs: Vec<Action> = serde_yaml::from_str(
45//! r#"
46//! - name: confirm-action
47//!   interaction:
48//!     kind: confirm
49//!     prompt: are you sure?
50//!     out: confirm
51//! - name: input-action
52//!   interaction:
53//!     kind: input
54//!     prompt: which city?
55//!     default: dallas
56//!     out: city
57//! - name: select-action
58//!   interaction:
59//!     kind: select
60//!     prompt: select transport
61//!     options:
62//!     - bus
63//!     - train
64//!     - walk
65//!     default: bus
66//!     out: transport
67//!   run: echo {{city}} {{transport}}
68//! "#).unwrap();
69//!
70//! let mut actions = ActionRunner::default();
71//! let mut v = VarBag::new();
72//! actions.run(&actions_defs, Some(Path::new(".")), &mut v, ActionHook::After, None::<fn(&Action) -> ()>);
73//!```
74//!
75//!
76#![warn(missing_docs)]
77#![allow(clippy::must_use_candidate)]
78#![allow(clippy::module_name_repetitions)]
79#![allow(clippy::use_self)]
80#![allow(clippy::missing_const_for_fn)]
81
82pub mod data;
83
84use anyhow::{Error, Result};
85use data::{Action, ActionHook, ActionResult, Response, RunResult, VarBag};
86use requestty_ui::events::{KeyEvent, TestEvents};
87use run_script::IoOptions;
88use std::path::Path;
89use std::vec::IntoIter;
90
91///
92/// Runs [`Action`]s and keeps track of variables in `varbag`.
93///
94#[derive(Default)]
95pub struct ActionRunner {
96    /// synthetic events to be injected to prompts, useful in tests
97    pub events: Option<TestEvents<IntoIter<KeyEvent>>>,
98}
99
100impl ActionRunner {
101    /// create with actions. does not run them yet.
102    /// create with actions and a set of synthetic events for testing
103    pub fn with_events(events: Vec<KeyEvent>) -> Self {
104        Self {
105            events: Some(TestEvents::new(events)),
106        }
107    }
108
109    /// Runs actions
110    ///
111    /// # Errors
112    ///
113    /// This function will return an error when actions fail
114    #[allow(clippy::needless_pass_by_value)]
115    pub fn run<P>(
116        &mut self,
117        actions: &[Action],
118        working_dir: Option<&Path>,
119        varbag: &mut VarBag,
120        hook: ActionHook,
121        progress: Option<P>,
122    ) -> Result<Vec<ActionResult>>
123    where
124        P: Fn(&Action),
125    {
126        actions
127            .iter()
128            .filter(|action| action.hook == hook)
129            .map(|action| {
130                // get interactive response from the user if any is defined
131                if let Some(ref progress) = progress {
132                    progress(action);
133                }
134
135                let response = action
136                    .interaction
137                    .as_ref()
138                    .map_or(Ok(Response::None), |interaction| {
139                        interaction.play(Some(varbag), self.events.as_mut())
140                    });
141
142                // with the defined run script and user response, perform an action
143                response.and_then(|r| match (r, action.run.as_ref()) {
144                    (Response::Cancel, _) => {
145                        if action.break_if_cancel {
146                            Err(anyhow::anyhow!("stop requested (break_if_cancel)"))
147                        } else {
148                            Ok(ActionResult {
149                                name: action.name.clone(),
150                                run: None,
151                                response: Response::Cancel,
152                            })
153                        }
154                    }
155                    (resp, None) => Ok(ActionResult {
156                        name: action.name.clone(),
157                        run: None,
158                        response: resp,
159                    }),
160                    (resp, Some(run)) => {
161                        let mut options = run_script::ScriptOptions::new();
162                        options.working_directory = working_dir.map(std::path::Path::to_path_buf);
163                        options.output_redirection = if action.capture {
164                            IoOptions::Pipe
165                        } else {
166                            IoOptions::Inherit
167                        };
168                        options.print_commands = true;
169                        let args = vec![];
170
171                        // varbag replacements: {{interaction.outvar}} -> value
172                        let script = varbag.iter().fold(run.clone(), |acc, (k, v)| {
173                            acc.replace(&format!("{{{{{k}}}}}"), v)
174                        });
175
176                        run_script::run(script.as_str(), &args, &options)
177                            .map_err(Error::msg)
178                            .and_then(|tup| {
179                                if !action.ignore_exit && tup.0 != 0 {
180                                    anyhow::bail!(
181                                        "in action '{}': command returned exit code '{}'",
182                                        action.name,
183                                        tup.0
184                                    )
185                                }
186                                Ok(tup)
187                            })
188                            .map(|(code, out, err)| ActionResult {
189                                name: action.name.clone(),
190                                run: Some(RunResult {
191                                    script,
192                                    code,
193                                    out,
194                                    err,
195                                }),
196                                response: resp,
197                            })
198                    }
199                })
200            })
201            .collect::<Result<Vec<_>>>()
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use insta::assert_debug_snapshot;
209    use requestty_ui::events::KeyCode;
210
211    #[test]
212    fn test_interaction() {
213        let actions_defs: Vec<Action> = serde_yaml::from_str(
214            r#"
215- name: confirm-action
216  interaction:
217    kind: confirm
218    prompt: are you sure?
219    out: confirm
220- name: input-action
221  interaction:
222    kind: input
223    prompt: which city?
224    default: dallas
225    out: city
226- name: select-action
227  interaction:
228    kind: select
229    prompt: select transport
230    options:
231    - bus
232    - train
233    - walk
234    default: bus
235"#,
236        )
237        .unwrap();
238        let events = vec![
239            KeyCode::Char('y').into(), // confirm: y
240            KeyCode::Enter.into(),     //
241            KeyCode::Char('t').into(), // city: 'tlv'
242            KeyCode::Char('l').into(), //
243            KeyCode::Char('v').into(), //
244            KeyCode::Enter.into(),     //
245            KeyCode::Down.into(),      // select: train
246            KeyCode::Enter.into(),     //
247        ];
248        let mut actions = ActionRunner::with_events(events);
249        let mut v = VarBag::new();
250        assert_debug_snapshot!(actions
251            .run(
252                &actions_defs,
253                Some(Path::new(".")),
254                &mut v,
255                ActionHook::After,
256                None::<&fn(&Action) -> ()>
257            )
258            .unwrap());
259        assert_debug_snapshot!(v);
260    }
261
262    #[test]
263    #[cfg(not(target_os = "windows"))]
264    fn test_run_script() {
265        let actions_defs: Vec<Action> = serde_yaml::from_str(
266            r#"
267    - name: input-action
268      interaction:
269        kind: input
270        prompt: which city?
271        default: dallas
272        out: city
273      run: echo {{city}}
274      capture: true
275    "#,
276        )
277        .unwrap();
278        let events = vec![
279            KeyCode::Char('t').into(), // city: 'tlv'
280            KeyCode::Char('l').into(), //
281            KeyCode::Char('v').into(), //
282            KeyCode::Enter.into(),     //
283        ];
284        let mut actions = ActionRunner::with_events(events);
285        let mut v = VarBag::new();
286
287        insta::assert_yaml_snapshot!(actions
288            .run(
289                &actions_defs,
290                Some(Path::new(".")),
291                &mut v,
292                ActionHook::After,
293            None::<&fn(&Action) -> ()>)
294            .unwrap(),  {
295            "[0].run.err" => ""
296        });
297
298        assert_debug_snapshot!(v);
299    }
300}