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}