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
132        // Build pre-compile context (wasm_paths is empty before compilation).
133        let wasm_out_dir =
134            self.build.out_dir.clone().unwrap_or_else(|| {
135                stellar_build::deps::stellar_wasm_out_dir(target_dir.as_std_path())
136            });
137        let source_dirs: Vec<std::path::PathBuf> = packages
138            .iter()
139            .filter_map(|p| p.manifest_path.parent())
140            .map(|p| p.as_std_path().to_path_buf())
141            .collect();
142        let pre_compile_ctx = CompileContext {
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            project_root: workspace_root.to_path_buf(),
173            env: scaffold_env.to_string(),
174            wasm_out_dir,
175            source_dirs,
176            wasm_paths,
177        };
178
179        extension::run_hook(
180            &extensions,
181            HookName::PostCompile,
182            &post_compile_ctx,
183            &printer,
184        )
185        .await;
186
187        if self.build_clients {
188            let mut build_clients_args = self.build_clients_args.clone();
189            // Pass through the workspace_root, out_dir, global_args, and printer
190            build_clients_args.workspace_root = Some(metadata.workspace_root.into_std_path_buf());
191            build_clients_args.out_dir.clone_from(&self.build.out_dir);
192            build_clients_args.global_args = Some(global_args.clone());
193            build_clients_args.extensions = extensions;
194            build_clients_args.compile_ctx = Some(post_compile_ctx);
195            build_clients_args
196                .run(packages.iter().map(|p| p.name.replace('-', "_")).collect())
197                .await?;
198        }
199
200        Ok(())
201    }
202
203    fn packages(&self, metadata: &Metadata) -> Result<Vec<Package>, Error> {
204        if let Some(package) = &self.build.package {
205            let package = metadata
206                .packages
207                .iter()
208                .find(|p| p.name == *package)
209                .ok_or_else(|| Error::PackageNotFound {
210                    package: package.clone(),
211                })?
212                .clone();
213            let manifest_path = package.manifest_path.clone().into_std_path_buf();
214            let mut contracts = stellar_build::deps::contract(&manifest_path)?;
215            contracts.push(package);
216            return Ok(contracts);
217        }
218        Ok(metadata
219            .packages
220            .iter()
221            .filter(|p| {
222                // Filter crates by those that build to cdylib (wasm)
223                p.targets
224                    .iter()
225                    .any(|t| t.crate_types.iter().any(|c| c == "cdylib"))
226            })
227            .cloned()
228            .collect())
229    }
230
231    pub(crate) fn metadata(&self) -> Result<Metadata, cargo_metadata::Error> {
232        let mut cmd = MetadataCommand::new();
233        cmd.no_deps();
234        // Set the manifest path if one is provided, otherwise rely on the cargo
235        // commands default behavior of finding the nearest Cargo.toml in the
236        // current directory, or the parent directories above it.
237        if let Some(manifest_path) = &self.build.manifest_path {
238            cmd.manifest_path(manifest_path);
239        }
240        // Do not configure features on the metadata command, because we are
241        // only collecting non-dependency metadata, features have no impact on
242        // the output.
243        cmd.exec()
244    }
245
246    fn create_cmd(&self, p: &Package, target_dir: &Utf8PathBuf) -> Result<Cmd, Error> {
247        let mut cmd = self.build.clone();
248        cmd.out_dir = cmd.out_dir.or_else(|| {
249            Some(stellar_build::deps::stellar_wasm_out_dir(
250                target_dir.as_std_path(),
251            ))
252        });
253
254        // Name is required in Cargo toml, so it should fail regardless
255        if p.name.is_empty() {
256            return Err(EmptyPackageName(p.manifest_path.clone()));
257        }
258
259        cmd.package = Some(p.name.clone());
260
261        let mut meta_map = BTreeMap::new();
262
263        meta_map.insert("scaffold_version".to_string(), version::pkg().to_string());
264
265        if let Value::Object(map) = &p.metadata
266            && let Some(val) = &map.get("stellar")
267            && let Value::Object(stellar_meta) = val
268        {
269            // When cargo_inherit is set, copy meta from Cargo toml
270            if let Some(Value::Bool(true)) = stellar_meta.get("cargo_inherit") {
271                meta_map.insert("name".to_string(), p.name.clone());
272
273                if !p.version.to_string().is_empty() {
274                    meta_map.insert("binver".to_string(), p.version.to_string());
275                }
276                if !p.authors.is_empty() {
277                    meta_map.insert("authors".to_string(), p.authors.join(", "));
278                }
279                if let Some(homepage) = p.homepage.clone() {
280                    meta_map.insert("homepage".to_string(), homepage);
281                }
282                if let Some(repository) = p.repository.clone() {
283                    meta_map.insert("repository".to_string(), repository);
284                }
285            }
286            Self::rec_add_meta(String::new(), &mut meta_map, val);
287            // Reserved keys
288            meta_map.remove("rsver");
289            meta_map.remove("rssdkver");
290            meta_map.remove("cargo_inherit");
291            // Rename some fields
292            if let Some(version) = meta_map.remove("version") {
293                meta_map.insert("binver".to_string(), version);
294            }
295            if let Some(repository) = meta_map.remove("repository") {
296                meta_map.insert("source_repo".to_string(), repository);
297            }
298            if let Some(homepage) = meta_map.remove("homepage") {
299                meta_map.insert("home_domain".to_string(), homepage);
300            }
301        }
302        cmd.meta.extend(meta_map);
303        Ok(cmd)
304    }
305
306    fn rec_add_meta(prefix: String, meta_map: &mut BTreeMap<String, String>, value: &Value) {
307        match value {
308            Value::Null => {}
309            Value::Bool(bool) => {
310                meta_map.insert(prefix, bool.to_string());
311            }
312            Value::Number(n) => {
313                meta_map.insert(prefix, n.to_string());
314            }
315            Value::String(s) => {
316                meta_map.insert(prefix, s.clone());
317            }
318            Value::Array(array) => {
319                if array.iter().all(Self::is_simple) {
320                    let s = array
321                        .iter()
322                        .map(|x| match x {
323                            Value::String(str) => str.clone(),
324                            _ => x.to_string(),
325                        })
326                        .collect::<Vec<_>>()
327                        .join(",");
328                    meta_map.insert(prefix, s);
329                } else {
330                    for (pos, e) in array.iter().enumerate() {
331                        Self::rec_add_meta(format!("{prefix}[{pos}]"), meta_map, e);
332                    }
333                }
334            }
335            Value::Object(map) => {
336                let mut separator = "";
337                if !prefix.is_empty() {
338                    separator = ".";
339                }
340                map.iter().for_each(|(k, v)| {
341                    Self::rec_add_meta(format!("{prefix}{separator}{}", k.clone()), meta_map, v);
342                });
343            }
344        }
345    }
346
347    fn is_simple(val: &Value) -> bool {
348        !matches!(val, Value::Array(_) | Value::Object(_))
349    }
350}