turdle_client/
commander.rs

1use crate::{
2    idl::{self, Idl},
3    program_client_generator, Client,
4};
5use cargo_metadata::{MetadataCommand, Package};
6use fehler::{throw, throws};
7use futures::future::try_join_all;
8use log::debug;
9use solana_sdk::signer::keypair::Keypair;
10use std::{borrow::Cow, io, iter, path::Path, process::Stdio, string::FromUtf8Error};
11use thiserror::Error;
12use tokio::{
13    fs,
14    io::AsyncWriteExt,
15    process::{Child, Command},
16};
17
18pub static PROGRAM_CLIENT_DIRECTORY: &str = ".program_client";
19
20#[derive(Error, Debug)]
21pub enum Error {
22    #[error("{0:?}")]
23    Io(#[from] io::Error),
24    #[error("{0:?}")]
25    Utf8(#[from] FromUtf8Error),
26    #[error("localnet is not running")]
27    LocalnetIsNotRunning,
28    #[error("localnet is still running")]
29    LocalnetIsStillRunning,
30    #[error("build programs failed")]
31    BuildProgramsFailed,
32    #[error("testing failed")]
33    TestingFailed,
34    #[error("read program code failed: '{0}'")]
35    ReadProgramCodeFailed(String),
36    #[error("{0:?}")]
37    Idl(#[from] idl::Error),
38    #[error("{0:?}")]
39    TomlDeserialize(#[from] toml::de::Error),
40    #[error("parsing Cargo.toml dependencies failed")]
41    ParsingCargoTomlDependenciesFailed,
42}
43
44/// Localnet (the validator process) handle.
45pub struct LocalnetHandle {
46    solana_test_validator_process: Child,
47}
48
49impl LocalnetHandle {
50    /// Stops the localnet.
51    ///
52    /// _Note_: Manual kill: `kill -9 $(lsof -t -i:8899)`
53    ///
54    /// # Errors
55    ///
56    /// It fails when:
57    /// - killing the process failed.
58    /// - process is still running after the kill command has been performed.
59    #[throws]
60    pub async fn stop(mut self) {
61        self.solana_test_validator_process.kill().await?;
62        if Client::new(Keypair::new()).is_localnet_running(false).await {
63            throw!(Error::LocalnetIsStillRunning);
64        }
65        debug!("localnet stopped");
66    }
67
68    /// Stops the localnet and removes the ledger.
69    ///
70    /// _Note_: Manual kill: `kill -9 $(lsof -t -i:8899)`
71    ///
72    /// # Errors
73    ///
74    /// It fails when:
75    /// - killing the process failed.
76    /// - process is still running after the kill command has been performed.
77    /// - cannot remove localnet data (the `test-ledger` directory).
78    #[throws]
79    pub async fn stop_and_remove_ledger(self) {
80        self.stop().await?;
81        fs::remove_dir_all("test-ledger").await?;
82        debug!("ledger removed");
83    }
84}
85
86/// `Commander` allows you to start localnet, build programs,
87/// run tests and do other useful operations.
88pub struct Commander {
89    root: Cow<'static, str>,
90}
91
92impl Commander {
93    /// Creates a new `Commander` instance with the default root `"../../"`.
94    pub fn new() -> Self {
95        Self {
96            root: "../../".into(),
97        }
98    }
99
100    /// Creates a new `Commander` instance with the provided `root`.
101    pub fn with_root(root: impl Into<Cow<'static, str>>) -> Self {
102        Self { root: root.into() }
103    }
104
105    /// Builds programs (smart contracts).
106    #[throws]
107    pub async fn build_programs(&self) {
108        let success = Command::new("cargo")
109            .arg("build-bpf")
110            .arg("--")
111            // prevent prevent dependency loop:
112            // program tests -> program_client -> program
113            .args(["-Z", "avoid-dev-deps"])
114            .spawn()?
115            .wait()
116            .await?
117            .success();
118        if !success {
119            throw!(Error::BuildProgramsFailed);
120        }
121    }
122
123    /// Runs standard Rust tests.
124    ///
125    /// _Note_: The [--nocapture](https://doc.rust-lang.org/cargo/commands/cargo-test.html#display-options) argument is used
126    /// to allow you read `println` outputs in your terminal window.
127    #[throws]
128    pub async fn run_tests(&self) {
129        let success = Command::new("cargo")
130            .arg("test")
131            .arg("--")
132            .arg("--nocapture")
133            .spawn()?
134            .wait()
135            .await?
136            .success();
137        if !success {
138            throw!(Error::TestingFailed);
139        }
140    }
141
142    /// Creates the `program_client` crate.
143    ///
144    /// It's used internally by the [`#[trdelnik_test]`](trdelnik_test::trdelnik_test) macro.
145    #[throws]
146    pub async fn create_program_client_crate(&self) {
147        let crate_path = Path::new(self.root.as_ref()).join(PROGRAM_CLIENT_DIRECTORY);
148        if fs::metadata(&crate_path).await.is_ok() {
149            return;
150        }
151
152        // @TODO Would it be better to:
153        // zip the template folder -> embed the archive to the binary -> unzip to a given location?
154
155        fs::create_dir(&crate_path).await?;
156
157        let cargo_toml_content = include_str!(concat!(
158            env!("CARGO_MANIFEST_DIR"),
159            "/src/templates/program_client/Cargo.toml.tmpl"
160        ));
161        fs::write(crate_path.join("Cargo.toml"), &cargo_toml_content).await?;
162
163        let src_path = crate_path.join("src");
164        fs::create_dir(&src_path).await?;
165
166        let lib_rs_content = include_str!(concat!(
167            env!("CARGO_MANIFEST_DIR"),
168            "/src/templates/program_client/lib.rs"
169        ));
170        fs::write(src_path.join("lib.rs"), &lib_rs_content).await?;
171
172        debug!("program_client crate created")
173    }
174
175    /// Returns an [Iterator] of program [Package]s read from `Cargo.toml` files.
176    pub fn program_packages(&self) -> impl Iterator<Item = Package> {
177        let cargo_toml_data = MetadataCommand::new()
178            .no_deps()
179            .exec()
180            .expect("Cargo.toml reading failed");
181
182        cargo_toml_data.packages.into_iter().filter(|package| {
183            // @TODO less error-prone test if the package is a _program_?
184            if let Some("programs") = package.manifest_path.iter().nth_back(2) {
185                return true;
186            }
187            false
188        })
189    }
190
191    /// Updates the `program_client` dependencies.
192    ///
193    /// It's used internally by the [`#[trdelnik_test]`](trdelnik_test::trdelnik_test) macro.
194    #[throws]
195    pub async fn generate_program_client_deps(&self) {
196        let trdelnik_dep = r#"turdle-client = "0.1.0""#.parse().unwrap();
197        // let trdelnik_dep = r#"trdelnik-client = "0.4.0""#.parse().unwrap();
198        // @TODO replace the line above with the specific version or commit hash
199        // when Trdelnik is released or when its repo is published.
200        // Or use both variants - path for Trdelnik repo/dev and version/commit for users.
201        // Some related snippets:
202        //
203        // println!("Trdelnik Version: {}", std::env!("VERGEN_BUILD_SEMVER"));
204        // println!("Trdelnik Commit: {}", std::env!("VERGEN_GIT_SHA"));
205        // https://docs.rs/vergen/latest/vergen/#environment-variables
206        //
207        // `trdelnik = "0.1.0"`
208        // `trdelnik = { git = "https://github.com/Ackee-Blockchain/trdelnik.git", rev = "cf867aea87e67d7be029982baa39767f426e404d" }`
209
210        let absolute_root = fs::canonicalize(self.root.as_ref()).await?;
211
212        let program_deps = self.program_packages().map(|package| {
213            let name = package.name;
214            let path = package
215                .manifest_path
216                .parent()
217                .unwrap()
218                .strip_prefix(&absolute_root)
219                .unwrap();
220            format!(r#"{name} = {{ path = "../{path}", features = ["no-entrypoint"] }}"#)
221                .parse()
222                .unwrap()
223        });
224
225        let cargo_toml_path = Path::new(self.root.as_ref())
226            .join(PROGRAM_CLIENT_DIRECTORY)
227            .join("Cargo.toml");
228
229        let mut cargo_toml_content: toml::Value =
230            fs::read_to_string(&cargo_toml_path).await?.parse()?;
231
232        let cargo_toml_deps = cargo_toml_content
233            .get_mut("dependencies")
234            .and_then(toml::Value::as_table_mut)
235            .ok_or(Error::ParsingCargoTomlDependenciesFailed)?;
236
237        for dep in iter::once(trdelnik_dep).chain(program_deps) {
238            if let toml::Value::Table(table) = dep {
239                let (name, value) = table.into_iter().next().unwrap();
240                cargo_toml_deps.entry(name).or_insert(value);
241            }
242        }
243
244        // @TODO remove renamed or deleted programs from deps?
245
246        fs::write(cargo_toml_path, cargo_toml_content.to_string()).await?;
247    }
248
249    /// Updates the `program_client` `lib.rs`.
250    ///
251    /// It's used internally by the [`#[trdelnik_test]`](trdelnik_test::trdelnik_test) macro.
252    #[throws]
253    pub async fn generate_program_client_lib_rs(&self) {
254        let idl_programs = self.program_packages().map(|package| async move {
255            let name = package.name;
256            let output = Command::new("cargo")
257                .arg("+nightly")
258                .arg("rustc")
259                .args(["--package", &name])
260                .arg("--profile=check")
261                .arg("--")
262                .arg("-Zunpretty=expanded")
263                .output()
264                .await?;
265            if output.status.success() {
266                let code = String::from_utf8(output.stdout)?;
267                Ok(idl::parse_to_idl_program(name, &code).await?)
268            } else {
269                let error_text = String::from_utf8(output.stderr)?;
270                Err(Error::ReadProgramCodeFailed(error_text))
271            }
272        });
273        let idl = Idl {
274            programs: try_join_all(idl_programs).await?,
275        };
276        let use_tokens = self.parse_program_client_imports().await?;
277        let program_client = program_client_generator::generate_source_code(idl, &use_tokens);
278        let program_client = Self::format_program_code(&program_client).await?;
279
280        let rust_file_path = Path::new(self.root.as_ref())
281            .join(PROGRAM_CLIENT_DIRECTORY)
282            .join("src/lib.rs");
283        fs::write(rust_file_path, &program_client).await?;
284    }
285
286    /// Formats program code.
287    #[throws]
288    pub async fn format_program_code(code: &str) -> String {
289        let mut rustfmt = Command::new("rustfmt")
290            .args(["--edition", "2018"])
291            .kill_on_drop(true)
292            .stdin(Stdio::piped())
293            .stdout(Stdio::piped())
294            .spawn()?;
295        if let Some(stdio) = &mut rustfmt.stdin {
296            stdio.write_all(code.as_bytes()).await?;
297        }
298        let output = rustfmt.wait_with_output().await?;
299        String::from_utf8(output.stdout)?
300    }
301
302    /// Starts the localnet (Solana validator).
303    #[throws]
304    pub async fn start_localnet(&self) -> LocalnetHandle {
305        let mut process = Command::new("solana-test-validator")
306            .arg("-C")
307            .arg([&self.root, "config.yml"].concat())
308            .arg("-r")
309            .arg("-q")
310            .spawn()?;
311        if !Client::new(Keypair::new()).is_localnet_running(true).await {
312            // The validator might not be running, but the process might be still alive (very slow start, some bug, ...),
313            // therefore we want to kill it if it's still running so ports aren't held.
314            process.kill().await.ok();
315            throw!(Error::LocalnetIsNotRunning);
316        }
317        debug!("localnet started");
318        LocalnetHandle {
319            solana_test_validator_process: process,
320        }
321    }
322
323    /// Returns `use` modules / statements
324    /// The goal of this method is to find all `use` statements defined by the user in the `.program_client`
325    /// crate. It solves the problem with regenerating the program client and removing imports defined by
326    /// the user.
327    #[throws]
328    pub async fn parse_program_client_imports(&self) -> Vec<syn::ItemUse> {
329        let output = Command::new("cargo")
330            .arg("+nightly")
331            .arg("rustc")
332            .args(["--package", "program_client"])
333            .arg("--profile=check")
334            .arg("--")
335            .arg("-Zunpretty=expanded")
336            .output()
337            .await?;
338        let code = String::from_utf8(output.stdout)?;
339        let mut use_modules: Vec<syn::ItemUse> = vec![];
340        for item in syn::parse_file(code.as_str()).unwrap().items.into_iter() {
341            if let syn::Item::Mod(module) = item {
342                let modules = module
343                    .content
344                    .ok_or("account mod: empty content")
345                    .unwrap()
346                    .1
347                    .into_iter();
348                for module in modules {
349                    if let syn::Item::Use(u) = module {
350                        use_modules.push(u);
351                    }
352                }
353            }
354        }
355        if use_modules.is_empty() {
356            use_modules.push(syn::parse_quote! { use trdelnik_client::*; })
357        }
358        use_modules
359    }
360}
361
362impl Default for Commander {
363    /// Creates a new `Commander` instance with the default root `"../../"`.
364    fn default() -> Self {
365        Self::new()
366    }
367}