Skip to main content

stellar_scaffold_cli/commands/build/
mod.rs

1#![allow(clippy::struct_excessive_bools)]
2use crate::commands::build::Error::EmptyPackageName;
3use crate::commands::version;
4use crate::extension;
5use cargo_metadata::camino::Utf8PathBuf;
6use cargo_metadata::{Metadata, MetadataCommand, Package};
7use clap::Parser;
8use clients::ScaffoldEnv;
9use serde_json::Value;
10use std::collections::BTreeMap;
11use std::{fmt::Debug, io, path::Path, process::ExitStatus};
12use stellar_cli::commands::contract::build::Cmd;
13use stellar_cli::commands::{contract::build, global};
14use stellar_cli::print::Print;
15use stellar_scaffold_ext_types::{CompileContext, HookName};
16
17pub mod clients;
18pub mod docker;
19pub mod env_toml;
20
21/// Build a contract from source
22///
23/// Builds all crates that are referenced by the cargo manifest (Cargo.toml)
24/// that have cdylib as their crate-type. Crates are built for the wasm32
25/// target. Unless configured otherwise, crates are built with their default
26/// features and with their release profile.
27///
28/// To view the commands that will be executed, without executing them, use the
29/// --print-commands-only option.
30#[derive(Parser, Debug, Clone)]
31pub struct Command {
32    /// List package names in order of build
33    #[arg(long, visible_alias = "ls")]
34    pub list: bool,
35    #[command(flatten)]
36    pub build: build::Cmd,
37    /// Build client code in addition to building the contract
38    #[arg(long)]
39    pub build_clients: bool,
40    #[command(flatten)]
41    pub build_clients_args: clients::Args,
42}
43
44#[derive(thiserror::Error, Debug)]
45pub enum Error {
46    #[error(transparent)]
47    Metadata(#[from] cargo_metadata::Error),
48    #[error(transparent)]
49    EnvironmentsToml(#[from] env_toml::Error),
50    #[error(transparent)]
51    CargoCmd(io::Error),
52    #[error("exit status {0}")]
53    Exit(ExitStatus),
54    #[error("package {package} not found")]
55    PackageNotFound { package: String },
56    #[error("creating out directory: {0}")]
57    CreatingOutDir(io::Error),
58    #[error("copying wasm file: {0}")]
59    CopyingWasmFile(io::Error),
60    #[error("getting the current directory: {0}")]
61    GettingCurrentDir(io::Error),
62    #[error(transparent)]
63    StellarBuild(#[from] stellar_build::deps::Error),
64    #[error(transparent)]
65    BuildClients(#[from] clients::Error),
66    #[error(transparent)]
67    Build(#[from] build::Error),
68    #[error("Failed to start docker container")]
69    DockerStart,
70    #[error("package name is empty: {0}")]
71    EmptyPackageName(Utf8PathBuf),
72}
73
74impl Command {
75    pub fn list_packages(&self, metadata: &Metadata) -> Result<Vec<Package>, Error> {
76        let packages = self.packages(metadata)?;
77        Ok(stellar_build::deps::get_workspace(&packages)?)
78    }
79
80    async fn start_local_docker_if_needed(
81        &self,
82        workspace_root: &Path,
83        env: &ScaffoldEnv,
84    ) -> Result<(), Error> {
85        if let Some(current_env) = env_toml::Environment::get(workspace_root, env)?
86            && current_env.network.run_locally
87        {
88            docker::start_local_stellar().await.map_err(|e| {
89                eprintln!("Failed to start Stellar Docker container: {e:?}");
90                Error::DockerStart
91            })?;
92        }
93        Ok(())
94    }
95
96    pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
97        let printer = Print::new(global_args.quiet);
98        let metadata = self.metadata()?;
99        let packages = self.list_packages(&metadata)?;
100        let workspace_root = metadata.workspace_root.as_std_path();
101
102        if let Some(env) = &self.build_clients_args.env
103            && env == &ScaffoldEnv::Development
104        {
105            printer.infoln("Starting local Stellar Docker container...");
106            self.start_local_docker_if_needed(workspace_root, env)
107                .await?;
108            printer.checkln("Local Stellar network is healthy and running.");
109        }
110
111        if self.list {
112            for p in packages {
113                println!("{}", p.name);
114            }
115            return Ok(());
116        }
117
118        let target_dir = &metadata.target_directory;
119
120        // Discover extensions for the active environment
121        let scaffold_env = self
122            .build_clients_args
123            .env
124            .unwrap_or(ScaffoldEnv::Development);
125        let extensions = match env_toml::Environment::get(workspace_root, &scaffold_env)? {
126            Some(env_config) if !env_config.extensions.is_empty() => {
127                extension::discover(&env_config.extensions, &printer)
128            }
129            _ => vec![],
130        };
131        // Build pre-compile context (wasm_paths is empty before compilation).
132        let wasm_out_dir =
133            self.build.out_dir.clone().unwrap_or_else(|| {
134                stellar_build::deps::stellar_wasm_out_dir(target_dir.as_std_path())
135            });
136        let source_dirs: Vec<std::path::PathBuf> = packages
137            .iter()
138            .filter_map(|p| p.manifest_path.parent())
139            .map(|p| p.as_std_path().to_path_buf())
140            .collect();
141        let pre_compile_ctx = CompileContext {
142            config: None,
143            project_root: workspace_root.to_path_buf(),
144            env: scaffold_env.to_string(),
145            wasm_out_dir: wasm_out_dir.clone(),
146            source_dirs: source_dirs.clone(),
147            wasm_paths: BTreeMap::new(),
148        };
149
150        extension::run_hook(
151            &extensions,
152            HookName::PreCompile,
153            &pre_compile_ctx,
154            &printer,
155        )
156        .await;
157
158        for p in &packages {
159            self.create_cmd(p, target_dir)?.run(global_args)?;
160        }
161
162        // Build post-compile context with populated wasm_paths.
163        let wasm_paths: BTreeMap<String, std::path::PathBuf> = packages
164            .iter()
165            .map(|p| {
166                let name = p.name.replace('-', "_");
167                let path = stellar_build::stellar_wasm_out_file(target_dir.as_std_path(), &name);
168                (name, path)
169            })
170            .collect();
171        let post_compile_ctx = CompileContext {
172            config: None,
173            project_root: workspace_root.to_path_buf(),
174            env: scaffold_env.to_string(),
175            wasm_out_dir,
176            source_dirs,
177            wasm_paths,
178        };
179
180        extension::run_hook(
181            &extensions,
182            HookName::PostCompile,
183            &post_compile_ctx,
184            &printer,
185        )
186        .await;
187
188        if self.build_clients {
189            let mut build_clients_args = self.build_clients_args.clone();
190            // Pass through the workspace_root, out_dir, global_args, and printer
191            build_clients_args.workspace_root = Some(metadata.workspace_root.into_std_path_buf());
192            build_clients_args.out_dir.clone_from(&self.build.out_dir);
193            build_clients_args.global_args = Some(global_args.clone());
194            build_clients_args.extensions = extensions;
195            build_clients_args.compile_ctx = Some(post_compile_ctx);
196            build_clients_args
197                .run(packages.iter().map(|p| p.name.replace('-', "_")).collect())
198                .await?;
199        }
200
201        Ok(())
202    }
203
204    fn packages(&self, metadata: &Metadata) -> Result<Vec<Package>, Error> {
205        if let Some(package) = &self.build.package {
206            let package = metadata
207                .packages
208                .iter()
209                .find(|p| p.name == *package)
210                .ok_or_else(|| Error::PackageNotFound {
211                    package: package.clone(),
212                })?
213                .clone();
214            let manifest_path = package.manifest_path.clone().into_std_path_buf();
215            let mut contracts = stellar_build::deps::contract(&manifest_path)?;
216            contracts.push(package);
217            return Ok(contracts);
218        }
219        Ok(metadata
220            .packages
221            .iter()
222            .filter(|p| {
223                // Filter crates by those that build to cdylib (wasm)
224                p.targets
225                    .iter()
226                    .any(|t| t.crate_types.iter().any(|c| c == "cdylib"))
227            })
228            .cloned()
229            .collect())
230    }
231
232    pub(crate) fn metadata(&self) -> Result<Metadata, cargo_metadata::Error> {
233        let mut cmd = MetadataCommand::new();
234        cmd.no_deps();
235        // Set the manifest path if one is provided, otherwise rely on the cargo
236        // commands default behavior of finding the nearest Cargo.toml in the
237        // current directory, or the parent directories above it.
238        if let Some(manifest_path) = &self.build.manifest_path {
239            cmd.manifest_path(manifest_path);
240        }
241        // Do not configure features on the metadata command, because we are
242        // only collecting non-dependency metadata, features have no impact on
243        // the output.
244        cmd.exec()
245    }
246
247    fn create_cmd(&self, p: &Package, target_dir: &Utf8PathBuf) -> Result<Cmd, Error> {
248        let mut cmd = self.build.clone();
249        cmd.out_dir = cmd.out_dir.or_else(|| {
250            Some(stellar_build::deps::stellar_wasm_out_dir(
251                target_dir.as_std_path(),
252            ))
253        });
254
255        // Name is required in Cargo toml, so it should fail regardless
256        if p.name.is_empty() {
257            return Err(EmptyPackageName(p.manifest_path.clone()));
258        }
259
260        cmd.package = Some(p.name.clone());
261
262        let mut meta_map = BTreeMap::new();
263
264        meta_map.insert("scaffold_version".to_string(), version::pkg().to_string());
265
266        if let Value::Object(map) = &p.metadata
267            && let Some(val) = &map.get("stellar")
268            && let Value::Object(stellar_meta) = val
269        {
270            // When cargo_inherit is set, copy meta from Cargo toml
271            if let Some(Value::Bool(true)) = stellar_meta.get("cargo_inherit") {
272                meta_map.insert("name".to_string(), p.name.clone());
273
274                if !p.version.to_string().is_empty() {
275                    meta_map.insert("binver".to_string(), p.version.to_string());
276                }
277                if !p.authors.is_empty() {
278                    meta_map.insert("authors".to_string(), p.authors.join(", "));
279                }
280                if let Some(homepage) = p.homepage.clone() {
281                    meta_map.insert("homepage".to_string(), homepage);
282                }
283                if let Some(repository) = p.repository.clone() {
284                    meta_map.insert("repository".to_string(), repository);
285                }
286            }
287            Self::rec_add_meta(String::new(), &mut meta_map, val);
288            // Reserved keys
289            meta_map.remove("rsver");
290            meta_map.remove("rssdkver");
291            meta_map.remove("cargo_inherit");
292            // Rename some fields
293            if let Some(version) = meta_map.remove("version") {
294                meta_map.insert("binver".to_string(), version);
295            }
296            if let Some(repository) = meta_map.remove("repository") {
297                meta_map.insert("source_repo".to_string(), repository);
298            }
299            if let Some(homepage) = meta_map.remove("homepage") {
300                meta_map.insert("home_domain".to_string(), homepage);
301            }
302        }
303        cmd.meta.extend(meta_map);
304        Ok(cmd)
305    }
306
307    fn rec_add_meta(prefix: String, meta_map: &mut BTreeMap<String, String>, value: &Value) {
308        match value {
309            Value::Null => {}
310            Value::Bool(bool) => {
311                meta_map.insert(prefix, bool.to_string());
312            }
313            Value::Number(n) => {
314                meta_map.insert(prefix, n.to_string());
315            }
316            Value::String(s) => {
317                meta_map.insert(prefix, s.clone());
318            }
319            Value::Array(array) => {
320                if array.iter().all(Self::is_simple) {
321                    let s = array
322                        .iter()
323                        .map(|x| match x {
324                            Value::String(str) => str.clone(),
325                            _ => x.to_string(),
326                        })
327                        .collect::<Vec<_>>()
328                        .join(",");
329                    meta_map.insert(prefix, s);
330                } else {
331                    for (pos, e) in array.iter().enumerate() {
332                        Self::rec_add_meta(format!("{prefix}[{pos}]"), meta_map, e);
333                    }
334                }
335            }
336            Value::Object(map) => {
337                let mut separator = "";
338                if !prefix.is_empty() {
339                    separator = ".";
340                }
341                map.iter().for_each(|(k, v)| {
342                    Self::rec_add_meta(format!("{prefix}{separator}{}", k.clone()), meta_map, v);
343                });
344            }
345        }
346    }
347
348    fn is_simple(val: &Value) -> bool {
349        !matches!(val, Value::Array(_) | Value::Object(_))
350    }
351}