Skip to main content

stellar_scaffold_cli/commands/build/
env_toml.rs

1use indexmap::IndexMap;
2use serde::Deserialize;
3use std::collections::BTreeMap as Map;
4use std::path::Path;
5use toml::value::Table;
6
7use crate::commands::build::clients::ScaffoldEnv;
8
9pub const ENV_FILE: &str = "environments.toml";
10
11#[derive(thiserror::Error, Debug)]
12pub enum Error {
13    #[error("⛔ ️parsing environments.toml: {0}")]
14    ParsingToml(#[from] toml::de::Error),
15    #[error("⛔ ️no settings for current STELLAR_SCAFFOLD_ENV ({0:?}) found in environments.toml")]
16    NoSettingsForCurrentEnv(String),
17    #[error("⛔ ️reading environments.toml as a string: {0}")]
18    ParsingString(#[from] std::io::Error),
19}
20
21type Environments = Map<Box<str>, Environment>;
22
23/// A single extension entry parsed from `environments.toml`.
24///
25/// Extensions are declared with two independent keys that can be used together
26/// or separately:
27///
28/// ```toml
29/// [development]
30/// extensions = ["reporter", "indexer"]   # execution order
31///
32/// [development.ext.reporter]             # optional per-extension config
33/// warn_size_kb = 128
34///
35/// [development.ext.indexer]
36/// storage = "sqlite"
37/// events = ["transfer", "mint"]
38/// ```
39///
40/// `extensions` controls which extensions run and in what order. `ext.<name>`
41/// tables are optional — omitting one means that extension receives no config.
42/// An extension listed in `ext` but absent from `extensions` is ignored.
43#[derive(Debug, Clone)]
44pub struct ExtensionEntry {
45    /// Extension name as declared in `extensions = [...]` (matches the
46    /// executable name the scaffold tool will invoke).
47    pub name: String,
48    /// Arbitrary JSON value parsed from the `[env.ext.<name>]` sub-table.
49    /// `None` when no config table exists for this extension, or when its
50    /// table is empty.
51    pub config: Option<serde_json::Value>,
52}
53
54#[derive(Debug, Clone)]
55pub struct Environment {
56    pub accounts: Option<Vec<Account>>,
57    pub network: Network,
58    pub contracts: Option<IndexMap<Box<str>, Contract>>,
59    /// Extensions to invoke for this environment, in execution order.
60    pub extensions: Vec<ExtensionEntry>,
61}
62
63fn deserialize_accounts<'de, D>(deserializer: D) -> Result<Option<Vec<Account>>, D::Error>
64where
65    D: serde::Deserializer<'de>,
66{
67    let opt: Option<Vec<AccountRepresentation>> = Option::deserialize(deserializer)?;
68    Ok(opt.map(|vec| vec.into_iter().map(Account::from).collect()))
69}
70
71impl<'de> Deserialize<'de> for Environment {
72    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
73    where
74        D: serde::Deserializer<'de>,
75    {
76        #[derive(Deserialize)]
77        struct EnvironmentHelper {
78            #[serde(default, deserialize_with = "deserialize_accounts")]
79            accounts: Option<Vec<Account>>,
80            network: Network,
81            contracts: Option<Table>,
82            /// Ordered list of extension names to invoke.
83            #[serde(default)]
84            extensions: Vec<String>,
85            /// Per-extension config tables, keyed by extension name.
86            ext: Option<Table>,
87        }
88
89        let helper = EnvironmentHelper::deserialize(deserializer)?;
90
91        let contracts = helper
92            .contracts
93            .map(|contracts_table| {
94                contracts_table
95                    .into_iter()
96                    .map(|(key, value)| {
97                        let contract: Contract =
98                            Contract::deserialize(value).map_err(serde::de::Error::custom)?;
99                        Ok((key.into_boxed_str(), contract))
100                    })
101                    .collect::<Result<IndexMap<_, _>, D::Error>>()
102            })
103            .transpose()?;
104
105        let extensions = parse_extensions(helper.extensions, helper.ext);
106
107        Ok(Environment {
108            accounts: helper.accounts,
109            network: helper.network,
110            contracts,
111            extensions,
112        })
113    }
114}
115
116/// Zips the ordered `extensions` name list with the optional `ext` config
117/// tables into a single [`ExtensionEntry`] list.
118///
119/// Extensions listed in `ext` but absent from `names` are silently ignored so
120/// that leftover config tables don't cause errors when an extension is
121/// temporarily removed from the run list.
122fn parse_extensions(names: Vec<String>, ext: Option<Table>) -> Vec<ExtensionEntry> {
123    let mut configs = ext.unwrap_or_default();
124    names
125        .into_iter()
126        .map(|name| {
127            let config = configs.remove(&name).and_then(|val| {
128                // An empty sub-table means "no config needed".
129                match &val {
130                    toml::Value::Table(t) if t.is_empty() => None,
131                    _ => serde_json::to_value(&val).ok(),
132                }
133            });
134            ExtensionEntry { name, config }
135        })
136        .collect()
137}
138
139#[derive(Debug, serde::Deserialize, Clone)]
140#[serde(rename_all = "kebab-case")]
141pub struct Network {
142    pub name: Option<String>,
143    pub rpc_url: Option<String>,
144    pub network_passphrase: Option<String>,
145    pub rpc_headers: Option<Vec<(String, String)>>,
146    #[serde(skip_serializing_if = "is_false", default)]
147    pub run_locally: bool,
148}
149
150#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
151#[serde(untagged)]
152pub enum AccountRepresentation {
153    Simple(String),
154    Detailed(Account),
155}
156
157#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
158pub struct Account {
159    pub name: String,
160    #[serde(default)]
161    pub default: bool,
162}
163
164impl From<AccountRepresentation> for Account {
165    fn from(rep: AccountRepresentation) -> Self {
166        match rep {
167            AccountRepresentation::Simple(name) => Account {
168                name,
169                default: false,
170            },
171            AccountRepresentation::Detailed(account) => account,
172        }
173    }
174}
175
176#[derive(Debug, Deserialize, Clone)]
177pub struct Contract {
178    #[serde(default = "default_client", skip_serializing_if = "std::ops::Not::not")]
179    pub client: bool,
180
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub after_deploy: Option<String>,
183
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub id: Option<String>,
186
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub constructor_args: Option<String>,
189}
190
191impl Default for Contract {
192    fn default() -> Self {
193        Self {
194            client: default_client(),
195            after_deploy: None,
196            id: None,
197            constructor_args: None,
198        }
199    }
200}
201
202fn default_client() -> bool {
203    true
204}
205
206impl Environment {
207    pub fn get(
208        workspace_root: &Path,
209        scaffold_env: &ScaffoldEnv,
210    ) -> Result<Option<Environment>, Error> {
211        let env_toml = workspace_root.join(ENV_FILE);
212
213        if !env_toml.exists() {
214            return Ok(None);
215        }
216
217        let toml_str = std::fs::read_to_string(env_toml)?;
218        let mut parsed_toml: Environments = toml::from_str(&toml_str)?;
219        let current_env = parsed_toml.remove(scaffold_env.to_string().as_str());
220        if current_env.is_none() {
221            return Err(Error::NoSettingsForCurrentEnv(scaffold_env.to_string()));
222        }
223        Ok(current_env)
224    }
225}
226
227impl From<&Network> for stellar_cli::config::network::Args {
228    fn from(network: &Network) -> Self {
229        stellar_cli::config::network::Args {
230            network: network.name.clone(),
231            rpc_url: network.rpc_url.clone(),
232            network_passphrase: network.network_passphrase.clone(),
233            rpc_headers: network.rpc_headers.clone().unwrap_or_default(),
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use serde_json::json;
242
243    /// Parse a TOML string that contains exactly one environment keyed by
244    /// `"development"` and return its [`Environment`].
245    fn parse_dev(toml: &str) -> Environment {
246        let mut envs: Environments = toml::from_str(toml).expect("invalid TOML");
247        envs.remove("development").expect("missing development key")
248    }
249
250    // Minimal required fields for a valid Environment.
251    const NETWORK_STUB: &str = r#"[development.network]
252name = "testnet"
253"#;
254
255    #[test]
256    fn extensions_with_config() {
257        let toml = format!(
258            r#"{NETWORK_STUB}
259[development]
260extensions = ["reporter", "indexer"]
261
262[development.ext.reporter]
263warn_size_kb = 128
264
265[development.ext.indexer]
266storage = "sqlite"
267events = ["transfer", "mint"]
268"#
269        );
270
271        let env = parse_dev(&toml);
272        assert_eq!(env.extensions.len(), 2);
273
274        let reporter = &env.extensions[0];
275        assert_eq!(reporter.name, "reporter");
276        assert_eq!(reporter.config, Some(json!({ "warn_size_kb": 128 })));
277
278        let indexer = &env.extensions[1];
279        assert_eq!(indexer.name, "indexer");
280        assert_eq!(
281            indexer.config,
282            Some(json!({ "storage": "sqlite", "events": ["transfer", "mint"] }))
283        );
284    }
285
286    #[test]
287    fn extensions_without_config() {
288        let toml = format!(
289            r#"{NETWORK_STUB}
290[development]
291extensions = ["reporter", "indexer"]
292"#
293        );
294
295        let env = parse_dev(&toml);
296        assert_eq!(env.extensions.len(), 2);
297
298        assert_eq!(env.extensions[0].name, "reporter");
299        assert!(env.extensions[0].config.is_none());
300
301        assert_eq!(env.extensions[1].name, "indexer");
302        assert!(env.extensions[1].config.is_none());
303    }
304
305    #[test]
306    fn extensions_empty_array() {
307        let toml = format!(
308            r"{NETWORK_STUB}
309[development]
310extensions = []
311"
312        );
313
314        let env = parse_dev(&toml);
315        assert!(env.extensions.is_empty());
316    }
317
318    #[test]
319    fn extensions_absent() {
320        let env = parse_dev(NETWORK_STUB);
321        assert!(env.extensions.is_empty());
322    }
323
324    #[test]
325    fn extensions_empty_ext_table() {
326        // An ext sub-table with no keys → config should be None (not Some({})).
327        let toml = format!(
328            r#"{NETWORK_STUB}
329[development]
330extensions = ["linter"]
331
332[development.ext.linter]
333"#
334        );
335
336        let env = parse_dev(&toml);
337        assert_eq!(env.extensions.len(), 1);
338        assert_eq!(env.extensions[0].name, "linter");
339        assert!(env.extensions[0].config.is_none());
340    }
341
342    #[test]
343    fn extensions_ext_table_without_listing_is_ignored() {
344        // An ext config for an extension not in the extensions array is ignored.
345        let toml = format!(
346            r#"{NETWORK_STUB}
347[development]
348extensions = ["reporter"]
349
350[development.ext.reporter]
351warn_size_kb = 128
352
353[development.ext.unlisted]
354some_key = "value"
355"#
356        );
357
358        let env = parse_dev(&toml);
359        assert_eq!(env.extensions.len(), 1);
360        assert_eq!(env.extensions[0].name, "reporter");
361    }
362}