stellar_scaffold_cli/commands/build/
mod.rs

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