odra_cli/cmd/
scenario.rs

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