Skip to main content

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}