sdkt_core/config.rs
1//! Core configuration engine for the Soroban DevKit (sdkt).
2//!
3//! This module defines the global configuration structures and handles
4//! the loading of configs from files, environment variables, and CLI overrides.
5
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::Path;
9
10/// High-level project-wide configuration.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
12pub struct DevKitConfig {
13 /// Soroban network settings.
14 #[serde(default)]
15 pub network: NetworkConfig,
16 /// XDR decoder configurations.
17 #[serde(default)]
18 pub decode: DecodeConfig,
19 /// Storage inspection settings.
20 #[serde(default)]
21 pub storage: StorageConfig,
22 /// Workspace contract configuration mapping.
23 #[serde(default)]
24 pub contracts: std::collections::HashMap<String, ContractConfig>,
25 /// Optional package manifest metadata (). Present when this project
26 /// is itself a publishable package.
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub package: Option<PackageConfig>,
29 /// Local and Git package dependencies ( / ). Keys are package
30 /// names; values describe exactly one source. A dependency is either a
31 /// local `path` reference or a `git` reference (with exactly one of
32 /// `tag` / `branch` / `rev`). No registry, no HTTP crate sources.
33 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
34 pub dependencies: std::collections::HashMap<String, Dependency>,
35}
36
37/// Settings for a specific contract in a workspace.
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub struct ContractConfig {
40 /// Path to the contract source directory (where Cargo.toml resides).
41 pub path: String,
42 /// Optional list of contract aliases that must be deployed before this one.
43 /// (`deploy_after` is the original spelling; `depends_on` is the canonical
44 /// 4.2 field. Both are accepted and merged during resolution.)
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
46 pub deploy_after: Vec<String>,
47 /// Canonical 4.2 dependency declaration. Semantically identical to
48 /// `deploy_after`; listed here for explicit package-dependency graphs.
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
50 pub depends_on: Vec<String>,
51}
52
53/// Package manifest metadata ().
54///
55/// Optional `[package]` section that marks a project as a reusable,
56/// publishable package. All fields are optional at parse time so that a
57/// partial/invalid manifest still deserializes; `validate_package` enforces
58/// the required fields (name, version) and version format with clear errors.
59/// Unknown fields are allowed (forward-compatible with future manifest keys
60/// such as `authors`, `license`, `repository`).
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
62pub struct PackageConfig {
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub name: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub version: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub description: Option<String>,
69}
70
71/// A Git reference selector for a [`Dependency`] ().
72///
73/// Exactly one variant must be set. `Tag` pins to a tag, `Branch` to a branch
74/// head, `Rev` to an exact commit SHA. This mirrors Cargo's git dependency
75/// reference semantics (minus `default-features`/subpath concerns outside the
76/// scope of ).
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78#[serde(rename_all = "lowercase")]
79pub enum GitReference {
80 /// A specific tag, e.g. `v1.2.0`.
81 Tag(String),
82 /// A branch head, e.g. `main`.
83 Branch(String),
84 /// An exact commit SHA.
85 Rev(String),
86}
87
88/// A single package dependency ( / ).
89///
90/// Exactly one source is allowed: a local `path` OR a `git` URL. When `git`
91/// is set, exactly one of `tag` / `branch` / `rev` must also be set (the
92/// [`crate::package::validate_dependencies`] resolver rejects any malformed
93/// combination). `deny_unknown_fields` rejects unrecognized keys so future
94/// source kinds (e.g. a registry) are explicit additions, not silent parses.
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
96#[serde(deny_unknown_fields)]
97pub struct Dependency {
98 /// Local filesystem path (relative to the depending manifest) to the
99 /// package root. Mutually exclusive with [`Dependency::git`].
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub path: Option<String>,
102 /// Git remote URL. Mutually exclusive with [`Dependency::path`].
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub git: Option<String>,
105 /// Git tag reference (e.g. `v1.2.0`). Mutually exclusive with `branch`/`rev`.
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub tag: Option<String>,
108 /// Git branch reference (e.g. `main`). Mutually exclusive with `tag`/`rev`.
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub branch: Option<String>,
111 /// Exact Git commit SHA. Mutually exclusive with `tag`/`branch`.
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub rev: Option<String>,
114 /// Optional semver version constraint for `git` deps resolved against the
115 /// remote's tags (). When set without `tag`/`branch`/`rev`, `sdkt package
116 /// update` selects the highest tag satisfying the constraint. An explicit
117 /// `tag`/`branch`/`rev` always takes precedence and the constraint is
118 /// ignored (mirrors how `rev`/`path` deps bypass version resolution).
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub version: Option<String>,
121}
122
123/// Backward-compatibility alias for the local-only dependency type.
124///
125/// `LocalDependency` previously held a single `path` field; it is now a thin
126/// type alias of [`Dependency`] so existing code/tests keep compiling.
127pub type LocalDependency = Dependency;
128
129/// Soroban network connection settings.
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
131pub struct NetworkConfig {
132 /// Target Soroban RPC URL.
133 pub rpc_url: String,
134 /// Core passphrase matching target network.
135 pub passphrase: String,
136 /// Optional timeout for RPC requests in seconds (default: 15).
137 pub timeout_secs: Option<u64>,
138 /// Optional maximum concurrent connections in the pool (default: 100).
139 pub pool_max_idle_per_host: Option<usize>,
140}
141
142/// Settings modifying XDR decoding actions.
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
144pub struct DecodeConfig {
145 /// Limit max depth during nested XDR parsing.
146 pub max_depth: usize,
147 /// Fallback to hex decoding if base64 detection fails.
148 pub allow_fallback_hex: bool,
149}
150
151/// Settings for Soroban storage inspection.
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
153pub struct StorageConfig {
154 /// Maximum number of storage entries to fetch per page.
155 pub max_entries: usize,
156 /// Number of days before TTL expiration to start warning.
157 pub ttl_warning_days: u32,
158}
159
160impl Default for StorageConfig {
161 fn default() -> Self {
162 Self {
163 max_entries: 200,
164 ttl_warning_days: 30,
165 }
166 }
167}
168
169impl Default for DecodeConfig {
170 fn default() -> Self {
171 Self {
172 max_depth: 32,
173 allow_fallback_hex: true,
174 }
175 }
176}
177
178impl Default for NetworkConfig {
179 fn default() -> Self {
180 Self {
181 rpc_url: "https://soroban-testnet.stellar.org".to_string(),
182 passphrase: "Test SDF Network ; September 2015".to_string(),
183 timeout_secs: Some(15),
184 pool_max_idle_per_host: Some(100),
185 }
186 }
187}
188
189impl DevKitConfig {
190 /// Loads configuration from a TOML string.
191 pub fn from_toml(content: &str) -> Result<Self, toml::de::Error> {
192 toml::from_str(content)
193 }
194
195 /// Loads configuration from a specific file path.
196 ///
197 /// If the file does not exist, it falls back to the default configuration.
198 /// A present-but-unparseable file (e.g. a duplicate contract name or
199 /// malformed TOML) returns the underlying parse error so callers can
200 /// surface a clear, human-readable message instead of silently falling
201 /// back to an empty config.
202 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
203 let path = path.as_ref();
204 if path.exists() {
205 let content = fs::read_to_string(path)?;
206 let config = Self::from_toml(&content)?;
207 Ok(config)
208 } else {
209 Ok(Self::default())
210 }
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn test_default_config() {
220 let default_config = DevKitConfig::default();
221 assert_eq!(default_config.decode.max_depth, 32);
222 assert!(default_config.decode.allow_fallback_hex);
223 assert_eq!(default_config.storage.max_entries, 200);
224 assert_eq!(default_config.storage.ttl_warning_days, 30);
225 }
226
227 #[test]
228 fn test_parse_valid_toml() {
229 let toml_data = r#"
230 [network]
231 rpc_url = "http://localhost:8000"
232 passphrase = "Standalone Network"
233
234 [decode]
235 max_depth = 16
236 allow_fallback_hex = false
237
238 [storage]
239 max_entries = 100
240 ttl_warning_days = 7
241 "#;
242 let parsed = DevKitConfig::from_toml(toml_data).unwrap();
243 assert_eq!(parsed.network.rpc_url, "http://localhost:8000");
244 assert_eq!(parsed.decode.max_depth, 16);
245 assert!(!parsed.decode.allow_fallback_hex);
246 assert_eq!(parsed.storage.max_entries, 100);
247 assert_eq!(parsed.storage.ttl_warning_days, 7);
248 }
249
250 #[test]
251 fn test_old_toml_without_storage() {
252 let toml_data = r#"
253 [network]
254 rpc_url = "http://localhost:8000"
255 passphrase = "Standalone Network"
256
257 [decode]
258 max_depth = 16
259 allow_fallback_hex = false
260 "#;
261 let parsed = DevKitConfig::from_toml(toml_data).unwrap();
262 assert_eq!(parsed.storage.max_entries, 200);
263 }
264
265 #[test]
266 fn test_duplicate_contract_name_rejected() {
267 // Duplicate `[contracts.token]` tables are a duplicate-name error.
268 // toml rejects duplicate keys, so parsing must fail (the CLI then
269 // surfaces a clear "duplicate key" message instead of silently
270 // defaulting to an empty config).
271 let toml_data = r#"
272 [contracts.token]
273 path = "contracts/token"
274
275 [contracts.token]
276 path = "contracts/token2"
277 "#;
278 let parsed = DevKitConfig::from_toml(toml_data);
279 assert!(
280 parsed.is_err(),
281 "duplicate contract name must fail to parse"
282 );
283 }
284
285 #[test]
286 fn test_depends_on_field_parses() {
287 let toml_data = r#"
288 [contracts.token]
289 path = "contracts/token"
290
291 [contracts.router]
292 path = "contracts/router"
293 depends_on = ["token"]
294 "#;
295 let parsed = DevKitConfig::from_toml(toml_data).unwrap();
296 assert_eq!(
297 parsed.contracts.get("router").unwrap().depends_on,
298 vec!["token".to_string()]
299 );
300 assert!(parsed
301 .contracts
302 .get("router")
303 .unwrap()
304 .deploy_after
305 .is_empty());
306 }
307}