trdelnik_sandbox_client/
test_generator.rs

1use crate::{
2    commander::{Commander, Error as CommanderError},
3    config::{Config, CARGO_TOML, TRDELNIK_TOML},
4};
5use fehler::throws;
6use std::{
7    env, io,
8    path::{Path, PathBuf},
9};
10use thiserror::Error;
11use tokio::fs;
12use toml::{value::Table, Value};
13
14const TESTS_WORKSPACE: &str = "trdelnik-tests";
15const TESTS_DIRECTORY: &str = "tests";
16const TESTS_FILE_NAME: &str = "test.rs";
17
18#[derive(Error, Debug)]
19pub enum Error {
20    #[error("cannot parse Cargo.toml")]
21    CannotParseCargoToml,
22    #[error("{0:?}")]
23    Io(#[from] io::Error),
24    #[error("{0:?}")]
25    Toml(#[from] toml::de::Error),
26    #[error("{0:?}")]
27    Commander(#[from] CommanderError),
28}
29
30pub struct TestGenerator;
31impl Default for TestGenerator {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36impl TestGenerator {
37    pub fn new() -> Self {
38        Self
39    }
40
41    /// Builds all the programs and creates `.program_client` directory. Initializes the
42    /// `trdelnik-tests/tests` directory with all the necessary files. Adds the
43    /// `test.rs` file and generates `Cargo.toml` with `dev-dependencies`. Updates root's `Cargo.toml`
44    /// workspace members.
45    ///
46    /// The crate is generated from `trdelnik-tests` template located in `client/src/templates`.
47    ///
48    /// Before you start writing trdelnik tests do not forget to add your program as a dependency
49    /// to the `trdelnik-tests/Cargo.toml`. For example:
50    ///
51    /// ```toml
52    /// # <project_root>/trdelnik-tests/Cargo.toml
53    /// # ...
54    /// [dev-dependencies]
55    /// my-program = { path = "../programs/my-program" }
56    /// # ...
57    /// ```
58    ///
59    /// Then you can easily use it in tests:
60    ///
61    /// ```ignore
62    /// use my_program;
63    ///
64    /// // ...
65    ///
66    /// #[trdelnik_test]
67    /// async fn test() {
68    ///     // ...
69    ///     my_program::do_something(/*...*/);
70    ///     // ...
71    /// }
72    /// ```
73    ///
74    /// # Errors
75    ///
76    /// It fails when:
77    /// - there is not a root directory (no `Anchor.toml` file)
78    #[throws]
79    pub async fn generate(&self) {
80        let root = Config::discover_root().expect("failed to find the root folder");
81        let root_path = root.to_str().unwrap().to_string();
82        let commander = Commander::with_root(root_path);
83        commander.create_program_client_crate().await?;
84        self.generate_test_files(&root).await?;
85        self.update_workspace(&root).await?;
86        self.build_program_client(&commander).await?;
87    }
88
89    /// Builds and generates programs for `program_client` module
90    #[throws]
91    async fn build_program_client(&self, commander: &Commander) {
92        commander.build_programs().await?;
93        commander.generate_program_client_deps().await?;
94        commander.generate_program_client_lib_rs().await?;
95    }
96
97    /// Creates the `trdelnik-tests` workspace with `tests` directory and empty `test.rs` file
98    /// finally it generates the `Cargo.toml` file. Crate is generated from `trdelnik-tests`
99    /// template located in `client/src/templates`
100    #[throws]
101    async fn generate_test_files(&self, root: &Path) {
102        let workspace_path = root.join(TESTS_WORKSPACE);
103        self.create_directory(&workspace_path, TESTS_WORKSPACE)
104            .await?;
105
106        let tests_path = workspace_path.join(TESTS_DIRECTORY);
107        self.create_directory(&tests_path, TESTS_DIRECTORY).await?;
108        let test_path = tests_path.join(TESTS_FILE_NAME);
109        let test_content = include_str!(concat!(
110            env!("CARGO_MANIFEST_DIR"),
111            "/src/templates/trdelnik-tests/test.rs"
112        ));
113        self.create_file(&test_path, TESTS_FILE_NAME, test_content)
114            .await?;
115
116        let cargo_toml_path = workspace_path.join(CARGO_TOML);
117        let cargo_toml_content = include_str!(concat!(
118            env!("CARGO_MANIFEST_DIR"),
119            "/src/templates/trdelnik-tests/Cargo.toml.tmpl"
120        ));
121        self.create_file(&cargo_toml_path, CARGO_TOML, cargo_toml_content)
122            .await?;
123        self.add_program_dev_deps(root, &cargo_toml_path).await?;
124
125        let trdelnik_toml_path = root.join(TRDELNIK_TOML);
126        let trdelnik_toml_content = include_str!(concat!(
127            env!("CARGO_MANIFEST_DIR"),
128            "/src/templates/Trdelnik.toml.tmpl"
129        ));
130        self.create_file(&trdelnik_toml_path, TRDELNIK_TOML, trdelnik_toml_content)
131            .await?;
132    }
133
134    /// Creates a new file with a given content on the specified `path` and `name`
135    // todo: the function should be located in the different module, File module for example
136    async fn create_file<'a>(
137        &self,
138        path: &'a PathBuf,
139        name: &str,
140        content: &str,
141    ) -> Result<&'a PathBuf, Error> {
142        match path.exists() {
143            true => println!("Skipping creating the {} file", name),
144            false => {
145                println!("Creating the {} file ...", name);
146                fs::write(path, content).await?;
147            }
148        };
149        Ok(path)
150    }
151
152    /// Creates a new directory on the specified `path` and with the specified `name`
153    // todo: the function should be located in the different module, File module for example
154    async fn create_directory<'a>(
155        &self,
156        path: &'a PathBuf,
157        name: &str,
158    ) -> Result<&'a PathBuf, Error> {
159        match path.exists() {
160            true => println!("Skipping creating the {} directory", name),
161            false => {
162                println!("Creating the {} directory ...", name);
163                fs::create_dir(path).await?;
164            }
165        };
166        Ok(path)
167    }
168
169    /// Adds `trdelnik-tests` workspace to the `root`'s `Cargo.toml` workspace members if needed.
170    #[throws]
171    async fn update_workspace(&self, root: &PathBuf) {
172        let cargo = Path::new(&root).join(CARGO_TOML);
173        let mut content: Value = fs::read_to_string(&cargo).await?.parse()?;
174        let test_workspace_value = Value::String(String::from(TESTS_WORKSPACE));
175        let members = content
176            .as_table_mut()
177            .ok_or(Error::CannotParseCargoToml)?
178            .entry("workspace")
179            .or_insert(Value::Table(Table::default()))
180            .as_table_mut()
181            .ok_or(Error::CannotParseCargoToml)?
182            .entry("members")
183            .or_insert(Value::Array(vec![test_workspace_value.clone()]))
184            .as_array_mut()
185            .ok_or(Error::CannotParseCargoToml)?;
186        match members.iter().find(|&x| x.eq(&test_workspace_value)) {
187            Some(_) => println!("Skipping updating project workspace"),
188            None => {
189                members.push(test_workspace_value);
190                println!("Project workspace successfully updated");
191            }
192        };
193        fs::write(cargo, content.to_string()).await?;
194    }
195
196    /// Adds programs to Cargo.toml as a dev dependencies to be able to be used in tests
197    #[throws]
198    async fn add_program_dev_deps(&self, root: &Path, cargo_toml_path: &Path) {
199        let programs = self.get_programs(root).await?;
200        if !programs.is_empty() {
201            println!("Adding programs to Cargo.toml ...");
202            let mut content: Value = fs::read_to_string(cargo_toml_path).await?.parse()?;
203            let dev_deps = content
204                .get_mut("dev-dependencies")
205                .and_then(Value::as_table_mut)
206                .ok_or(Error::CannotParseCargoToml)?;
207            for dep in programs {
208                if let Value::Table(table) = dep {
209                    let (name, value) = table.into_iter().next().unwrap();
210                    dev_deps.entry(name).or_insert(value);
211                }
212            }
213            fs::write(cargo_toml_path, content.to_string()).await?;
214        }
215    }
216
217    /// Scans `programs` directory and returns a list of `toml::Value` programs and their paths.
218    async fn get_programs(&self, root: &Path) -> Result<Vec<Value>, Error> {
219        let programs = root.join("programs");
220        if !programs.exists() {
221            println!("Programs folder does not exist. Skipping adding dev dependencies.");
222            return Ok(Vec::new());
223        }
224        println!("Searching for programs ...");
225        let mut program_names: Vec<Value> = vec![];
226        let programs = std::fs::read_dir(programs)?;
227        for program in programs {
228            let file = program?;
229            let file_name = file.file_name();
230            if file.path().is_dir() {
231                let path = file.path().join(CARGO_TOML);
232                if path.exists() {
233                    let name = file_name.to_str().unwrap();
234                    let dependency = self.get_program_dep(&path, name).await?;
235                    program_names.push(dependency);
236                }
237            }
238        }
239        Ok(program_names)
240    }
241
242    /// Gets the program name from `<program>/Cargo.toml` and returns a `toml::Value` program dependency.
243    #[throws]
244    async fn get_program_dep<'a>(&self, dir: &Path, dir_name: &'a str) -> Value {
245        let content: Value = fs::read_to_string(&dir).await?.parse()?;
246        let name = content
247            .get("package")
248            .and_then(Value::as_table)
249            .and_then(|table| table.get("name"))
250            .and_then(Value::as_str)
251            .ok_or(Error::CannotParseCargoToml)?;
252        format!("{} = {{ path = \"../programs/{}\" }}", name, dir_name)
253            .parse()
254            .unwrap()
255    }
256}