Skip to main content

odra_cli/cmd/
scenario.rs

1use std::any::Any;
2
3use crate::cmd::args::CommandArg;
4use crate::cmd::{CmdOutput, SCENARIOS_SUBCOMMAND};
5use crate::custom_types::CustomTypeSet;
6use crate::{container::ContractError, types, DeployedContractsContainer};
7use anyhow::Result;
8use clap::{ArgMatches, Command};
9use odra::casper_types::{CLTyped, CLValue};
10use odra::schema::NamedCLTyped;
11use odra::{casper_types::bytesrepr::FromBytes, host::HostEnv, prelude::OdraError};
12use thiserror::Error;
13
14use super::OdraCommand;
15
16#[derive(serde::Serialize)]
17pub(crate) struct ScenarioOutput {
18    success: bool
19}
20
21impl CmdOutput for ScenarioOutput {
22    fn pretty_print(&self) {
23        if self.success {
24            prettycli::info("Scenario executed successfully");
25        } else {
26            prettycli::error("Scenario failed");
27        }
28    }
29}
30
31/// Scenario is a trait that represents a custom scenario.
32///
33/// A scenario is a user-defined set of actions that can be run in the Odra CLI.
34/// If you want to run a custom scenario that calls multiple entry points,
35/// you need to implement this trait.
36pub trait Scenario: Any {
37    fn args(&self) -> Vec<CommandArg> {
38        vec![]
39    }
40    fn run(
41        &self,
42        env: &HostEnv,
43        container: &DeployedContractsContainer,
44        args: ScenarioArgs
45    ) -> core::result::Result<(), ScenarioError>;
46}
47
48#[derive(Default)]
49pub(crate) struct ScenariosCmd {
50    scenarios: Vec<ScenarioCmd>
51}
52
53impl ScenariosCmd {
54    pub fn add_scenario<S: ScenarioMetadata + Scenario>(&mut self, scenario: S) {
55        self.scenarios.push(ScenarioCmd::new(scenario));
56    }
57}
58
59impl OdraCommand for ScenariosCmd {
60    type Output = ScenarioOutput;
61
62    fn exec(
63        &self,
64        env: &HostEnv,
65        args: &ArgMatches,
66        types: &CustomTypeSet,
67        container: &DeployedContractsContainer
68    ) -> Result<Self::Output> {
69        args.subcommand()
70            .map(|(scenario_name, scenario_args)| {
71                self.scenarios
72                    .iter()
73                    .find(|cmd| cmd.name == scenario_name)
74                    .map(|scenario| scenario.exec(env, scenario_args, types, container))
75                    .unwrap_or(Err(anyhow::anyhow!("No scenario found")))
76            })
77            .unwrap_or(Err(anyhow::anyhow!("No scenario found")))
78    }
79}
80
81impl From<&ScenariosCmd> for Command {
82    fn from(value: &ScenariosCmd) -> Self {
83        Command::new(SCENARIOS_SUBCOMMAND)
84            .about("Commands for interacting with scenarios")
85            .subcommand_required(true)
86            .arg_required_else_help(true)
87            .subcommands(&value.scenarios)
88    }
89}
90
91/// ScenarioCmd is a struct that represents a scenario command in the Odra CLI.
92///
93/// The scenario command runs a [Scenario]. A scenario is a user-defined set of
94/// actions that can be run in the Odra CLI.
95struct ScenarioCmd {
96    name: String,
97    description: String,
98    scenario: Box<dyn Scenario>
99}
100
101impl ScenarioCmd {
102    pub fn new<S: ScenarioMetadata + Scenario>(scenario: S) -> Self {
103        ScenarioCmd {
104            name: S::NAME.to_string(),
105            description: S::DESCRIPTION.to_string(),
106            scenario: Box::new(scenario)
107        }
108    }
109}
110
111impl OdraCommand for ScenarioCmd {
112    type Output = ScenarioOutput;
113
114    fn exec(
115        &self,
116        env: &HostEnv,
117        args: &ArgMatches,
118        _types: &CustomTypeSet,
119        container: &DeployedContractsContainer
120    ) -> Result<Self::Output> {
121        let args = ScenarioArgs::new(args);
122        env.set_captures_events(false);
123        self.scenario.run(env, container, args)?;
124        Ok(ScenarioOutput { success: true })
125    }
126}
127
128impl From<&ScenarioCmd> for Command {
129    fn from(value: &ScenarioCmd) -> Self {
130        Command::new(&value.name)
131            .about(&value.description)
132            .args(value.scenario.args())
133    }
134}
135
136/// ScenarioError is an enum representing the different errors that can occur when running a scenario.
137#[derive(Debug, Error)]
138pub enum ScenarioError {
139    #[error("Odra error: {message}")]
140    OdraError { message: String },
141    #[error("Contract read error: {0}")]
142    ContractReadError(#[from] ContractError),
143    #[error("Arg error")]
144    ArgError(#[from] ArgError),
145    #[error("Types error")]
146    TypesError(#[from] types::Error),
147    #[error("Missing scenario argument: {0}")]
148    MissingScenarioArg(String)
149}
150
151impl From<OdraError> for ScenarioError {
152    fn from(err: OdraError) -> Self {
153        ScenarioError::OdraError {
154            message: format!("{:?}", err)
155        }
156    }
157}
158
159/// ScenarioArgs is a struct that represents the arguments passed to a scenario.
160pub struct ScenarioArgs<'a>(&'a ArgMatches);
161
162impl<'a> ScenarioArgs<'a> {
163    pub(crate) fn new(matches: &'a ArgMatches) -> Self {
164        Self(matches)
165    }
166
167    pub fn get_single<T: NamedCLTyped + FromBytes + CLTyped>(
168        &self,
169        name: &str
170    ) -> Result<T, ScenarioError> {
171        let arg = self
172            .0
173            .try_get_one::<CLValue>(name)
174            .map_err(|_| ScenarioError::ArgError(ArgError::UnexpectedArg(name.to_string())))?
175            .ok_or(ArgError::MissingArg(name.to_string()))?;
176
177        arg.clone()
178            .into_t::<T>()
179            .map_err(|_| ScenarioError::ArgError(ArgError::Deserialization(name.to_string())))
180    }
181
182    pub fn get_many<T: NamedCLTyped + FromBytes + CLTyped>(
183        &self,
184        name: &str
185    ) -> Result<Vec<T>, ScenarioError> {
186        self.0
187            .try_get_many::<CLValue>(name)
188            .map_err(|_| ScenarioError::ArgError(ArgError::UnexpectedArg(name.to_string())))?
189            .unwrap_or_default()
190            .collect::<Vec<_>>()
191            .into_iter()
192            .map(|value| value.clone().into_t::<T>())
193            .collect::<Result<Vec<T>, _>>()
194            .map_err(|_| ScenarioError::ArgError(ArgError::Deserialization(name.to_string())))
195    }
196}
197
198/// ArgError is an enum representing the different errors that can occur when parsing scenario arguments.
199#[derive(Debug, Error, PartialEq)]
200pub enum ArgError {
201    #[error("Arg `{0}` deserialization failed")]
202    Deserialization(String),
203    #[error("Multiple values expected")]
204    ManyExpected,
205    #[error("Single value expected")]
206    SingleExpected,
207    #[error("Missing arg: {0}")]
208    MissingArg(String),
209    #[error("Unexpected arg: {0}")]
210    UnexpectedArg(String)
211}
212
213/// ScenarioMetadata is a trait that represents the metadata of a scenario.
214pub trait ScenarioMetadata {
215    const NAME: &'static str;
216    const DESCRIPTION: &'static str;
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::test_utils;
223    use odra::schema::casper_contract_schema::NamedCLType;
224
225    struct TestScenario;
226
227    impl Scenario for TestScenario {
228        fn args(&self) -> Vec<CommandArg> {
229            vec![
230                CommandArg::new("arg1", "First argument", NamedCLType::U32).required(),
231                CommandArg::new("arg2", "Second argument", NamedCLType::String),
232                CommandArg::new("arg3", "Optional argument", NamedCLType::String).list(),
233            ]
234        }
235
236        fn run(
237            &self,
238            _env: &HostEnv,
239            _container: &DeployedContractsContainer,
240            args: ScenarioArgs
241        ) -> core::result::Result<(), ScenarioError> {
242            _ = args.get_single::<u32>("arg1")?;
243            _ = args.get_single::<String>("arg2")?;
244            _ = args.get_many::<String>("arg3")?;
245            Ok(())
246        }
247    }
248
249    impl ScenarioMetadata for TestScenario {
250        const NAME: &'static str = "test_scenario";
251        const DESCRIPTION: &'static str = "A test scenario for unit testing";
252    }
253
254    #[test]
255    fn test_scenarios_command() {
256        let mut scenarios = ScenariosCmd::default();
257        scenarios.add_scenario(TestScenario);
258
259        let cmd: Command = (&scenarios).into();
260
261        assert_eq!(cmd.get_name(), SCENARIOS_SUBCOMMAND);
262        assert!(cmd
263            .get_subcommands()
264            .any(|c| c.get_name() == "test_scenario"));
265    }
266
267    #[test]
268    fn test_match_required_args() {
269        let cmd = ScenarioCmd::new(TestScenario);
270        let cmd: Command = (&cmd).into();
271
272        let matches = cmd.try_get_matches_from(vec!["odra-cli", "--arg1", "1"]);
273        assert!(matches.is_ok());
274        assert_eq!(
275            matches.unwrap().get_one::<CLValue>("arg1"),
276            Some(&CLValue::from_t(1u32).unwrap())
277        );
278    }
279
280    #[test]
281    fn test_match_required_arg_missing() {
282        let cmd = ScenarioCmd::new(TestScenario);
283        let cmd: Command = (&cmd).into();
284
285        let matches = cmd.try_get_matches_from(vec!["odra-cli"]);
286        assert!(matches.is_err());
287        assert_eq!(
288            matches.unwrap_err().kind(),
289            clap::error::ErrorKind::MissingRequiredArgument
290        );
291    }
292
293    #[test]
294    fn test_match_required_arg_with_wrong_type() {
295        let cmd = ScenarioCmd::new(TestScenario);
296        let cmd: Command = (&cmd).into();
297
298        let matches = cmd.try_get_matches_from(vec!["odra-cli", "--arg1", "not_a_number"]);
299        assert!(matches.is_err());
300        assert_eq!(
301            matches.unwrap_err().kind(),
302            clap::error::ErrorKind::InvalidValue
303        );
304    }
305
306    #[test]
307    fn test_match_optional_args() {
308        let cmd = ScenarioCmd::new(TestScenario);
309        let cmd: Command = (&cmd).into();
310
311        let matches = cmd.try_get_matches_from(vec![
312            "odra-cli", "--arg1", "1", "--arg2", "test", "--arg3", "value1", "--arg3", "value2",
313        ]);
314        assert!(matches.is_ok());
315        let matches = matches.unwrap();
316        assert_eq!(
317            matches.get_one::<CLValue>("arg1"),
318            Some(&CLValue::from_t(1u32).unwrap())
319        );
320        assert_eq!(
321            matches.get_one::<CLValue>("arg2"),
322            Some(&CLValue::from_t("test".to_string()).unwrap())
323        );
324        assert_eq!(
325            matches
326                .get_many::<CLValue>("arg3")
327                .unwrap()
328                .collect::<Vec<_>>(),
329            vec![
330                &CLValue::from_t("value1".to_string()).unwrap(),
331                &CLValue::from_t("value2".to_string()).unwrap()
332            ]
333        );
334    }
335
336    #[test]
337    fn test_matching_list_args() {
338        let scenario = ScenarioCmd::new(TestScenario);
339        let cmd: Command = (&scenario).into();
340
341        let matches = cmd.try_get_matches_from(vec![
342            "odra-cli", "--arg1", "1", "--arg3", "value1", "--arg3", "value2",
343        ]);
344        assert!(matches.is_ok());
345        let matches = matches.unwrap();
346        assert_eq!(
347            matches
348                .get_many::<CLValue>("arg3")
349                .unwrap()
350                .collect::<Vec<_>>(),
351            vec![
352                &CLValue::from_t("value1".to_string()).unwrap(),
353                &CLValue::from_t("value2".to_string()).unwrap()
354            ]
355        );
356
357        // If we pass `arg2` multiple times, it should return an error
358        let cmd: Command = (&scenario).into();
359        let matches = cmd.try_get_matches_from(vec![
360            "odra-cli", "--arg1", "1", "--arg2", "value1", "--arg2", "value2",
361        ]);
362
363        assert!(matches.is_err());
364        assert_eq!(
365            matches.unwrap_err().kind(),
366            clap::error::ErrorKind::ArgumentConflict
367        );
368    }
369
370    #[test]
371    fn test_scenario_args_get_single() {
372        let scenario = ScenarioCmd::new(TestScenario);
373        let cmd: Command = (&scenario).into();
374
375        let matches = cmd
376            .try_get_matches_from(vec!["odra-cli", "--arg1", "42", "--arg2", "test_value"])
377            .unwrap();
378
379        let args = ScenarioArgs::new(&matches);
380        let arg1: u32 = args.get_single("arg1").unwrap();
381        assert_eq!(arg1, 42);
382
383        let arg2: String = args.get_single("arg2").unwrap();
384        assert_eq!(arg2, "test_value");
385    }
386
387    #[test]
388    fn test_scenario_args_get_many() {
389        let scenario = ScenarioCmd::new(TestScenario);
390        let cmd: Command = (&scenario).into();
391
392        let matches = cmd
393            .try_get_matches_from(vec![
394                "odra-cli", "--arg1", "42", "--arg3", "value1", "--arg3", "value2",
395            ])
396            .unwrap();
397
398        let args = ScenarioArgs::new(&matches);
399        let arg3: Vec<String> = args.get_many("arg3").unwrap();
400        assert_eq!(arg3, vec!["value1".to_string(), "value2".to_string()]);
401    }
402
403    #[test]
404    fn test_run_cmd() {
405        let scenario = ScenarioCmd::new(TestScenario);
406        let cmd: Command = (&scenario).into();
407
408        let matches = cmd
409            .try_get_matches_from(vec![
410                "odra-cli",
411                "--arg1",
412                "42",
413                "--arg2",
414                "test_value",
415                "--arg3",
416                "value1",
417                "--arg3",
418                "value2",
419            ])
420            .unwrap();
421
422        let env = test_utils::mock_host_env();
423        let container = test_utils::mock_contracts_container();
424        let result = scenario.run(&env, &matches, &CustomTypeSet::default(), &container);
425        assert!(result.is_ok());
426    }
427
428    #[test]
429    fn test_scenario_args_missing_arg() {
430        let scenario = ScenarioCmd::new(TestScenario);
431        let cmd: Command = (&scenario).into();
432
433        let matches = cmd.get_matches_from(vec!["odra-cli", "--arg1", "1", "--arg2", "value1"]);
434        ScenarioArgs(&matches)
435            .get_single::<u32>("arg3")
436            .expect_err("Expected an error for missing arg3");
437
438        assert_eq!(ScenarioArgs(&matches).get_single::<u32>("arg1").unwrap(), 1);
439    }
440}